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 (
BrevyScribe{" "} Generic datasheets, not list-specific ones
A fancy way to print your Warhammer 40k datasheets
0 ? "" : "none", }} > Your rosters
{rostersJSON.map((r, index) => (
))}
Datasheets: {TRANSFORMS.map(([key, label, description]) => ( ))}
{roster && colorUserChoice && ( )}
{error}
{edition === 9 && } {(edition === 10 || edition === 11) && ( )}
Examples
About
BrevyScribe renders{" "} BattleScribe {" "} or New Recruit roster files in an opinionated format inspired by the 10th edition datacards.
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 "select one each turn" ability is split into one titled row per option. Use the{" "} Datasheets toggles above to turn any of that off.
BrevyScribe is a fork of{" "} FancyScribe {" "} by Nils Ueter, which does all the heavy lifting here. Additional inspiration and large parts of the parsing logic come from the{" "} PrettyScribe {" "} website.
Output Examples
); } 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;