Files
brevyscribe/src/App.jsx
T
2026-08-03 11:20:31 +02:00

661 lines
19 KiB
React

import JSZip from "jszip";
import { Create40kRoster } from "./roster40k";
import { Create40kRoster10th } from "./roster40k-10th";
import { useEffect, useMemo, useRef, useState } from "react";
import Demo0 from "./assets/Demo0.png";
import Demo1 from "./assets/Demo1.png";
import { Roster } from "./9th/Roster";
import { Roster as Roster10th } from "./10th/Roster";
import { useLocalStorage } from "./helpers/useLocalStorage";
import { parseJSON, stringifyJSON } from "./helpers/json";
import { Create40kRoster11th } from "./roster40k-11th";
import { applyTransforms, defaultToggles } from "./transforms";
// The transforms, in the order they are offered in the UI. These are what makes
// this a fork: they rewrite the roster into the generic datasheets the official
// cards print, rather than a record of one particular list.
const TRANSFORMS = [
[
"mergeDuplicateUnits",
"Merge duplicates",
"Fold copies of a datasheet - taken only because their wargear is mutually exclusive - into a single card showing every option.",
],
[
"removeLeaderAbilities",
"Drop Leader/Support",
"Remove the attachment rules. Once the army is built they say nothing you need mid-game, and they push the rules you do need off the card.",
],
[
"convertChoiceAbilities",
"Split choice abilities",
"Print each option of a 'select one each turn' ability as its own titled row, the way the official datasheets do.",
],
];
const PARSERS = {
"Warhammer 40,000 9th Edition": [Create40kRoster, 9],
"Warhammer 40,000 10th Edition": [Create40kRoster10th, 10],
"Warhammer 40,000 11th Edition": [Create40kRoster11th, 11],
};
const throttle = (func, limit) => {
let lastFunc;
let lastRan;
return (...args) => {
if (!lastRan) {
func(...args);
lastRan = Date.now();
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(
() => {
if (Date.now() - lastRan >= limit) {
func(...args);
lastRan = Date.now();
}
},
limit - (Date.now() - lastRan),
);
}
};
};
function App() {
// Saved rosters hold the *raw* roster XML rather than the parsed roster, so
// that flipping a transform toggle can rebuild them without a re-upload.
// The keys are BrevyScribe's own, so nothing upstream FancyScribe left in
// localStorage is read back in the wrong shape.
const [rosters, setRosters] = useLocalStorage("brevyscribe.rosters", "[]");
const rostersJSON = parseJSON(rosters ?? "[]");
const [savedToggles, setSavedToggles] = useLocalStorage(
"brevyscribe.transforms",
stringifyJSON(defaultToggles),
);
// Memoised so the rebuild effect can depend on the toggles themselves rather
// than their serialised form; a fresh object every render would re-run it.
const toggles = useMemo(
() => ({ ...defaultToggles, ...parseJSON(savedToggles ?? "{}") }),
[savedToggles],
);
// { xml, save } - the roster to show, and whether it belongs in the list.
const [source, setSource] = useState();
const [error, setError] = useState();
const [roster, setRoster] = useState();
const [edition, setEdition] = useState(10); // [9, 10, 11]
const [onePerPage, setOnePerPage] = useState(false);
const [showLore, setShowLore] = useState(true);
const [primaryColor, setPrimaryColor] = useState("#536766");
const [colorUserChoice, setColorUserChoice] = useState(false);
const uploadRef = useRef();
const throttledSetPrimaryColor = useRef(
throttle((color) => setPrimaryColor(color), 50),
).current;
async function handleFileSelect(event) {
const files = event?.target?.files;
if (files) {
const reader = new FileReader();
reader.onerror = () => {
reader.abort();
setError("Failed to read roster file.");
};
reader.onloadend = async () => {
const content = reader.result;
setSource({ xml: await unzip(content), save: true });
};
reader.readAsBinaryString(files[0]);
} else {
let example;
switch (event) {
case "ultras":
example = await fetch("Ultramarines Example.rosz");
break;
case "chaos daemons":
example = await fetch("Chaos Demons Mix.rosz");
break;
case "death guard":
example = await fetch("Death Guard Example.rosz");
break;
case "thousand sons":
example = await fetch("Thousand Sons Example.rosz");
break;
default:
break;
}
if (!example) return;
const arrayBuffer = await example.arrayBuffer();
// Create a new Blob object from the zip file contents
const zipBlob = new Blob([arrayBuffer], { type: "application/zip" });
setSource({ xml: await unzip(zipBlob), save: false });
}
}
// Already in the list, so there is nothing to save; the stored XML goes
// through the same rebuild as a fresh upload.
const loadFromLocalStorage = (saved) =>
setSource({ xml: saved.xml, save: false });
const unzip = async (file) => {
if (file?.charAt && file.charAt(0) !== "P") {
console.log(file.replace(/\u00a0/g, " "));
return file
.replaceAll("Â", " ")
.replaceAll("’", "'")
.replaceAll("â–", "■"); //  is a common issue with UTF-8 encoding in XML files
} else {
const jszip = new JSZip();
const zip = await jszip.loadAsync(file);
return zip.file(/[^/]+\.ros/)[0].async("string"); // Get roster files that are in the root
}
};
// Rebuild whenever a new roster arrives or a transform is toggled. The
// transforms run here rather than over the uploaded file, so flipping a
// toggle rebuilds from the original XML and nothing has to be re-uploaded.
//
// biome-ignore lint/correctness/useExhaustiveDependencies: the roster list is
// read to append to it, so depending on it would re-run this on its own write.
useEffect(() => {
if (!source?.xml) return;
const doc = new DOMParser().parseFromString(source.xml, "text/xml");
const info = doc?.querySelector("roster");
if (!info) return;
// Determine roster type (game system).
const gameType = info.getAttribute("gameSystemName");
if (!gameType) return;
const parser = PARSERS[gameType];
if (!parser) {
setError(`No support for game type '${gameType}'.`);
return;
}
const report = applyTransforms(doc, toggles);
const [createRoster, rosterEdition] = parser;
const built = createRoster(doc, gameType);
if (!built || built.forces.length === 0) return;
setRoster(built);
setEdition(rosterEdition);
setError("");
const name = info.getAttribute("name");
if (name) document.title = `BrevyScribe ${name}`;
console.log(built, report);
if (source.save) {
// Keyed on the XML, so re-saving the same roster moves it to the front
// of the list rather than adding a second copy.
setRosters(
stringifyJSON([
{ name: name || "Roster", xml: source.xml },
...rostersJSON.filter((entry) => entry.xml !== source.xml),
]),
);
}
}, [source, toggles, setRosters]);
useEffect(() => {
// Check if the browser is Safari, and if so, remove the accept attribute
// from the file input element. This is because Safari doesn't support
// extensions on the accept attribute for input type=file
// (https://caniuse.com/input-file-accept). If set, they will not allow any
// file to be selected.
if (
navigator.userAgent.match(/AppleWebKit.*Safari/) &&
!navigator.userAgent.includes("Chrome")
) {
uploadRef.current?.removeAttribute("accept");
}
}, []);
useEffect(() => {
if (roster) {
js_colorPicker.value = getPrimaryColor(roster.forces[0].catalog);
setPrimaryColor(getPrimaryColor(roster.forces[0].catalog));
setColorUserChoice(false);
}
}, [roster]);
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
};
useEffect(() => {
if (edition === 9) {
document.body.style.minWidth = "600px";
} else {
document.body.style.minWidth = "";
}
}, [edition]);
return (
<div
id="js_app"
className="App"
style={{
"--primary-color": primaryColor,
"--primary-color-transparent": primaryColor + "60",
}}
>
<div className="header print-display-none">
<a
href="/"
style={{
color: "#fff",
fontWeight: 800,
display: "flex",
flexDirection: "column",
}}
>
BrevyScribe{" "}
<span
style={{
fontSize: "0.8rem",
fontWeight: 400,
}}
>
Generic datasheets, not list-specific ones
</span>
</a>
<div className="subheader">
A fancy way to print your Warhammer 40k datasheets
</div>
</div>
<div className="body">
<div
className="print-display-none max-w-[95vw]"
style={{
display: "flex",
flexDirection: roster ? "row" : "column",
alignItems: "center",
gap: 8,
}}
>
<div
className="print-display-none"
style={{
fontSize: roster ? "1rem" : "1.7rem",
fontWeight: roster ? 600 : "",
display: rostersJSON.length > 0 ? "" : "none",
}}
>
Your rosters
</div>
<div
className="print-display-none"
style={{
gap: 8,
flexWrap: "wrap",
justifyContent: "center",
alignItems: "center",
display: "flex",
}}
>
{rostersJSON.map((r, index) => (
<div key={index} style={{ position: "relative" }}>
<button
className="print-display-none"
style={{ fontSize: roster ? "" : "1.2rem" }}
onClick={() => loadFromLocalStorage(r)}
>
{r.name}
</button>
<button
onClick={() =>
setRosters(
stringifyJSON(rostersJSON.filter((_, i) => i !== index)),
)
}
className="print-display-none absolute right-[-4px] top-[-4px] rounded-full border-0 bg-red-600 fill-white p-[2px] text-sm transition duration-150 ease-in-out hover:bg-red-800 focus:outline-none"
>
<svg height="16" viewBox="0 0 16 16" width="16">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
</div>
))}
</div>
</div>
<div className="print-display-none" />
<div
className="print-display-none max-w-[95vw]"
style={{ display: "flex", width: "100%", gap: 8 }}
>
<input
type="file"
ref={uploadRef}
accept=".ros,.rosz"
name="rosterUpload"
id="rosterUpload"
onChange={handleFileSelect}
style={{ display: "none" }}
/>
<label
htmlFor="rosterUpload"
className={"rosterUpload " + (roster ? "rosterUploaded" : "")}
id="rosterUploadContainer"
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
e.target.files = e.dataTransfer.files;
handleFileSelect(e);
}
}}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
>
<div id="preloadContainer">
<span>Upload roster file (.ros, .rosz)</span>
</div>
</label>
<button
style={{ display: roster ? "" : "none" }}
onClick={() => window.print()}
>
Print roster
</button>
</div>
<div
className="print-display-none max-w-[95vw]"
style={{
display: roster ? "flex" : "none",
width: "100%",
gap: 16,
flexWrap: "wrap",
}}
>
<span style={{ fontWeight: 600, minHeight: 26 }}>Datasheets:</span>
{TRANSFORMS.map(([key, label, description]) => (
<label
key={key}
title={description}
style={{
display: "flex",
alignItems: "center",
gap: 4,
minHeight: 26,
}}
>
<input
type="checkbox"
checked={toggles[key]}
onChange={(e) =>
setSavedToggles(
stringifyJSON({ ...toggles, [key]: e.target.checked }),
)
}
/>
<span className="select-none">{label}</span>
</label>
))}
</div>
<div
className="print-display-none max-w-[95vw]"
style={{ display: roster ? "flex" : "none", width: "100%", gap: 16 }}
>
<label
style={{
display: "flex",
alignItems: "center",
gap: 4,
minHeight: 26,
}}
>
<input
type="checkbox"
onChange={(e) => setOnePerPage(e.target.checked)}
/>
<span className="select-none">
One Datacard per Page when Printing
</span>
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 4,
minHeight: 26,
}}
title="Print the flavour text from public/Lore.csv beside the model image, the way the official datasheets do."
>
<input
type="checkbox"
checked={showLore}
onChange={(e) => setShowLore(e.target.checked)}
/>
<span className="select-none">Show Lore Text</span>
</label>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
<input
id="js_colorPicker"
type="color"
className="rounded-sm border border-gray-400"
style={{ height: 24, width: 32, padding: "0 2px" }}
onChange={(e) => {
setColorUserChoice(true);
throttledSetPrimaryColor(e.target.value);
}}
/>
<span> Custom Color</span>
</label>
{roster && colorUserChoice && (
<button
onClick={() => {
setPrimaryColor(getPrimaryColor(roster.forces[0].catalog));
setColorUserChoice(false);
js_colorPicker.value = getPrimaryColor(
roster.forces[0].catalog,
);
}}
style={{
padding: "2px 4px",
borderRadius: 4,
borderWidth: 1,
}}
>
Reset color
</button>
)}
</div>
</div>
<div className="print-display-none" style={{ color: "red" }}>
{error}
</div>
{edition === 9 && <Roster roster={roster} onePerPage={onePerPage} />}
{(edition === 10 || edition === 11) && (
<Roster10th
roster={roster}
onePerPage={onePerPage}
colorUserChoice={colorUserChoice}
primaryColor={primaryColor}
showLore={showLore}
/>
)}
<div
className="max-w-[95vw]"
style={{
paddingTop: 8,
fontSize: "1.7rem",
display: roster ? "none" : "flex",
}}
>
Examples
</div>
<div
className="max-w-[95vw]"
style={{
gap: 8,
flexWrap: "wrap",
justifyContent: "center",
display: roster ? "none" : "flex",
}}
>
<button
className="print-display-none"
style={{ fontSize: "1.2rem" }}
onClick={() => handleFileSelect("thousand sons")}
>
Thousand Sons (10th)
</button>
<button
className="print-display-none"
style={{ fontSize: "1.2rem" }}
onClick={() => handleFileSelect("death guard")}
>
Death Guard (10th)
</button>
<button
className="print-display-none"
style={{ fontSize: "1.2rem" }}
onClick={() => handleFileSelect("ultras")}
>
Ultramarines (9th)
</button>
<button
className="print-display-none"
style={{ fontSize: "1.2rem" }}
onClick={() => handleFileSelect("chaos daemons")}
>
Chaos Daemons (9th)
</button>
</div>
<div
className="print-display-none max-w-[95vw]"
style={{ display: roster ? "none" : "" }}
>
<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>About</div>
<div style={{ fontSize: "1.2rem" }}>
BrevyScribe renders{" "}
<a
href="https://www.battlescribe.net/"
target="_blank"
rel="noreferrer"
>
BattleScribe
</a>{" "}
or <a href="https://www.newrecruit.eu/">New Recruit</a> roster files
in an opinionated format inspired by the 10th edition datacards.
</div>
<div style={{ fontSize: "1.2rem" }}>
It differs from its upstream in what it prints: rather than a record
of one particular list, it rewrites the roster into the generic
datasheets the official cards show. Duplicate copies of a unit are
folded into one card carrying every option, the Leader and Support
attachment rules are dropped, and a &quot;select one each
turn&quot; ability is split into one titled row per option. Use the{" "}
<b>Datasheets</b> toggles above to turn any of that off.
</div>
<div style={{ fontSize: "1.2rem" }}>
BrevyScribe is a fork of{" "}
<a
href="https://github.com/NilsUeter/fancyscribe"
target="_blank"
rel="noreferrer"
>
FancyScribe
</a>{" "}
by Nils Ueter, which does all the heavy lifting here. Additional
inspiration and large parts of the parsing logic come from the{" "}
<a
href="https://rweyrauch.github.io/PrettyScribe"
target="_blank"
rel="noreferrer"
>
PrettyScribe
</a>{" "}
website.
</div>
<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>
Output Examples
</div>
<img src={Demo0} className="pb-4" style={{ width: "100%" }} />
<img src={Demo1} style={{ width: "100%" }} />
</div>
</div>
</div>
);
}
const getPrimaryColor = (catalog) => {
switch (catalog.replace("Xenos - ", "")) {
case "Imperium - Adeptus Astartes":
return "#536766";
case "Imperium - Adeptus Astartes - Blood Angels":
return "#761119";
case "Imperium - Adeptus Astartes - Space Wolves":
return "#3e646f";
case "Imperium - Adeptus Astartes - Imperial Fists":
return "#b87d00";
case "Imperium - Adeptus Astartes - Raven Guard":
return "#2b2b2b";
case "Imperium - Adeptus Astartes - Salamanders":
return "#1b623f";
case "Imperium - Adeptus Astartes - White Scars":
return "#783028";
case "Imperium - Adeptus Astartes - Dark Angels":
return "#014419";
case "Imperium - Adeptus Astartes - Black Templars":
return "#002f42";
case "Imperium - Adeptus Astartes - Deathwatch":
return "#44494d";
case "Imperium - Adeptus Custodes":
return "#765c41";
case "Imperium - Adeptus Mechanicus":
return "#a03332";
case "Imperium - Adepta Sororitas":
return "#5e0a00";
case "Imperium - Astra Militarum":
return "#375441";
case "Imperium - Grey Knights":
return "#4a6672";
case "Imperium - Imperial Knights":
return "#03495e";
case "Chaos - Daemons":
return "#383c46";
case "Chaos - Chaos Space Marines":
return "#1d3138";
case "Chaos - World Eaters":
return "#883531";
case "Chaos - Death Guard":
return "#576011";
case "Chaos - Chaos Knights":
return "#405c58";
case "Chaos - Thousand Sons":
return "#015d68";
case "Necrons":
return "#005c2f";
case "Orks":
return "#4b6621";
case "Tyranids":
case "Tyranids - Genestealer Cults":
return "#44264C";
case "Aeldari - Craftworlds":
return "#1f787f";
case "Aeldari - Harlequins":
return "#6f322f";
case "Leagues of Votann":
return "#7d4c08";
case "T'au Empire":
return "#206173";
default:
console.log(catalog);
return "#536766";
}
};
export default App;