Add lore texts

This commit is contained in:
2026-08-03 11:20:31 +02:00
parent 1846df6ac2
commit e7ecbcabc0
10 changed files with 2433 additions and 149 deletions
+151
View File
@@ -0,0 +1,151 @@
# CLAUDE.md
Guidance for agents that work in this repository. [README.md](README.md)
describes what the app does for its users. This file describes how it is built.
## Commands
```sh
npm install
npm run dev # http://localhost:5173
npm test # the parity suite, see "Tests"
npm run build # static files in dist/
npm run fixtures # regenerate test fixtures, see "Tests"
```
`npm run lint` reports findings that come from upstream, mostly
`a11y/useButtonType`. These findings are old, so lint is not part of CI. Before
you treat a finding as new, compare it against `git show upstream/main:<file>`.
## Where the transforms fit
```
upload/.rosz -> unzip -> DOMParser -> applyTransforms -> Create40kRoster* -> render
^
src/transforms/index.js
```
`src/App.jsx` runs the transforms between the parse of the XML and the build of
the roster. A toggle therefore rebuilds the cards from the original XML, and
the user uploads nothing a second time. For the same reason, a saved roster
holds the raw roster XML and not the parsed object.
[`src/transforms/config.js`](src/transforms/config.js) lists the abilities that
the transforms strip and split. When you find more of them, add them to
`abilitiesToStrip` and `abilitiesToConvert`.
## Lore text
[`src/helpers/lore.js`](src/helpers/lore.js) matches a roster name against
`public/Lore.csv` in three steps:
1. The normalized name. Case, accents, curly quotes and punctuation all differ
between the roster and the export.
2. The name with its last word in the singular form. Example: "Myphitic
Blight-haulers" against "Myphitic Blight-hauler".
3. The longest known name that the roster name *ends* with. Example: "Thousand
Sons Chaos Spawn" against "Chaos Spawn".
This search resolves every unit in the bundled 10th-edition examples.
`parseLore` finds columns by header name. If someone exports the file again
with a `faction` column, `parseLore` can match that column against the
catalogue of the force. Nothing else has to change. Today the export has no
such column, so the longest entry wins for a name with lore in more than one
faction.
Two properties of the card header are less obvious than they look:
- The italic text needs a **fourth font file**. `ConduitITCStd` shipped as
three upright faces, and `:root` in [`src/index.css`](src/index.css) sets
`font-synthesis: none`. A request for italic therefore printed upright text
and gave no warning. `public/fonts/ConduitITCStd Italic.woff2` and its
`@font-face` rule correct this. The element also sets
`font-synthesis: style`. As a result, a face that fails to load degrades to a
slanted upright face and not to no italic at all.
- The lore panel has absolute position, so it cannot make the header taller.
Without help, a long legend is clipped: the longest entry in the export
overruns a 15rem header at each width below approximately 1300px. A
`ResizeObserver` on the text feeds the `min-height` of the header instead.
The observer is necessary because the text wraps differently at each card
width.
Only the 10th-edition and 11th-edition renderers show lore. Leave the
9th-edition renderer in `src/9th/` alone. It lays out its header differently.
## Tests
The transforms began as three standalone Python scripts
(`merge_duplicate_units`, `remove_leader_abilities`, `convert_choice_abilities`)
that rewrote the `.ros` file before the upload to FancyScribe. The scripts are
gone, but the tests measure against their output.
`src/transforms/__fixtures__/` holds four 11th-edition rosters. For each roster
it also holds the output of those scripts: `<name>.{merge,strip,convert,all}.ros`.
The suite runs each transform over the input and asserts that the result is the
same roster. This is worth more than a snapshot of the current code, because
the expected output comes from a **different implementation**. A person
verified that implementation with printed cards.
CAUTION: Do not regenerate an existing fixture from the JavaScript code. The
test then compares the code against itself and passes whatever the code does.
`scripts/update-fixtures.mjs` therefore refuses to overwrite a fixture without
`--force`. Use the script when you add a new example roster, and read the diff:
```sh
# Put a new roster in src/transforms/__fixtures__/, then:
npm run fixtures
```
The comparison is structural, not textual. The Python scripts edited the file
as text and kept its format byte for byte. These transforms build DOM nodes, so
attribute order and whitespace differ legitimately. The suite compares
generated `id` and `typeId` values as *tokens*. The requirement is that ids are
shared and distinct in the same pattern, not that both implementations hash
alike.
`integration.test.js` pushes the transformed document through the real roster
parser and checks what lands on the card:
- one Skitarii card that carries both weapons
- the data-tether ability on the model that brings it
- no Support ability
- one row for each Canticle
The tests run under jsdom, which does not implement scoped selectors like a
browser. `force.querySelectorAll("force>selections>…")` finds nothing under
jsdom. A browser matches the selector against the whole tree and then keeps the
descendants of `force`, so the parser found no units at all. The two call sites
that depend on this behavior now use `:scope>…`, which works in both.
## Merges from upstream
FancyScribe is under active development, and 11th-edition support landed
recently. This fork touches little of it. The `upstream` remote is configured:
```sh
git fetch upstream
git merge upstream/main
```
Expect conflicts only in `src/App.jsx`, `index.html`, `vite.config.js` and
`package.json`. `src/transforms/` is completely new and never conflicts.
The fork also hides three things that upstream shows, because a generic
datasheet cannot use them: the roster overview card with its charts, the unit
composition, and the points cost of each unit. These decisions live in
[`src/fork.js`](src/fork.js), which upstream does not have.
`src/10th/Roster.jsx` uses them on as few lines as possible:
- It imports `ShortSummaryTable` from `../fork`, not from `./ShortSummaryTable`.
This one-line change leaves the render site untouched. The upstream component
stays in the tree, unused, so its future diffs continue to apply.
- `hideModelCount` is pinned to `HIDE_UNIT_COMPOSITION` and is no longer a
piece of checkbox state.
Only the two checkboxes and the `pts` span are deleted. A merge that touches
them therefore reports a conflict instead of quietly bringing them back.
`vite.config.js` sets `base: "/"`, because the app is served at the root of a
domain. Upstream sets `/fancyscribe` for GitHub Pages. If you serve the app
from a subpath, change this setting.
+89 -144
View File
@@ -1,56 +1,87 @@
# BrevyScribe # BrevyScribe
A fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe) that prints BrevyScribe is a fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe).
**generic datasheets** rather than a record of one particular army list. It prints **generic datasheets** instead of a record of one army list.
FancyScribe renders a BattleScribe or New Recruit roster as 10th-edition-style FancyScribe shows a BattleScribe or New Recruit roster as 10th-edition
datacards, showing exactly the wargear you picked. That is the right thing for a datacards. The cards show only the wargear that you selected. That is correct
list you are about to play, but the wrong thing for a reference card you want to for a list that you play today. It is not correct for a reference card that you
keep: the official cards show *every* option a unit could take. BrevyScribe keep, because the official cards show *every* option of a unit. BrevyScribe
rewrites the roster on the way in so the printed cards read like the official rewrites the roster before it renders the cards. As a result, the printed cards
ones. read like the official ones.
Three transforms do the work, all of them toggleable in the UI: Three transforms do this work. You can switch each one on or off in the user
interface.
| Transform | What it does | | Transform | What it does |
| --- | --- | | --- | --- |
| **Merge duplicates** | Mutually exclusive wargear forces you to take a datasheet twice - one Skatros with a radium jezzail, another with a transuranic arquebus. Copies of the same unit are folded into one card carrying every option. | | **Merge duplicates** | Mutually exclusive wargear makes you take one datasheet two times. One Skatros has a radium jezzail, another has a transuranic arquebus. This transform folds the copies of a unit into one card that carries all options. |
| **Drop Leader/Support** | Removes the attachment rules. Once the army is built they say nothing you need mid-game, and they are long enough to push the rules you *do* need off the card. | | **Drop Leader/Support** | This transform removes the attachment rules. After the army is built, these rules tell you nothing that you need during a game. They are also long, and they push the necessary rules off the card. |
| **Split choice abilities** | An ability like *Canticles of the Omnissiah* arrives as one blob of text. This splits it into the intro rule plus one titled row per option, which is how the datasheets print it. | | **Split choice abilities** | An ability such as *Canticles of the Omnissiah* arrives as one block of text. This transform divides it into the intro rule and one titled row for each option. The official datasheets print it in this form. |
Everything still runs in the browser. There is no server component, no upload, The app does all of its work in the browser. There is no server component, no
no account, and no analytics; rosters are held in `localStorage` and never leave upload, no account and no analytics. Rosters stay in `localStorage` and never
the machine. leave your machine.
## Running it ## Lore text
Official datasheets print a paragraph of flavor text to the right of the model
image. The **Show Lore Text** toggle prints this text. The card then gives the
right third of its image to the text, behind a gradient that fades the image
into the dark. Cards without an entry keep the full-width image.
[`public/Lore.csv`](public/Lore.csv) supplies the text. The file is a
pipe-delimited export in this form:
```
name|legend
Custodian Guard|These warriors form the backbone of the shield companies, ...
```
The app fetches this file only when a roster is on screen. A missing or bad
file means that the cards print without lore text.
The names in a roster do not match the export exactly, so the app searches for
the closest entry. This search is a heuristic, and you can correct it:
- To change the text of a card, edit it in place. The app keeps your edit in
`localStorage` under `lore_<unit name>`. If you clear the edit, the text of
the export comes back.
- If the export does not cover a unit, the card shows an **Add lore** button.
About 40 names carry lore for more than one faction. Chaos Daemons and Death
Guard both field Plaguebearers, and three armies field a Ministorum Priest. The
export has no column that separates them, so the longest entry wins.
Only the 10th-edition and 11th-edition cards show lore text. The 9th-edition
renderer lays out its header differently.
## Run it on your machine
```sh ```sh
npm install npm install
npm run dev # http://localhost:5173 npm run dev # http://localhost:5173
npm test # the parity suite, see below npm test
npm run build # static files into dist/ npm run build # static files in dist/
``` ```
`npm run lint` reports pre-existing findings inherited from upstream (mostly ## Deploy to scribe.luxick.de
`a11y/useButtonType`), so it is deliberately not part of CI. Compare against
`git show upstream/main:<file>` before treating any of them as new.
## Deploying to scribe.luxick.de The build output is **static files**. There is no application server and no
socket, so nginx serves the files directly. The app does all of its work in the
browser. For the same reason, upstream FancyScribe can live on GitHub Pages.
The build output is **static files** - no application server, no socket, nothing The server needs no Node. Do the setup one time, in two stages. The two stages
for nginx to proxy to. The app does all its work in the browser, which is why are necessary because the real configuration names a certificate, and nginx
upstream can live on GitHub Pages. refuses to load a configuration whose certificate does not exist. Therefore
nginx first comes up on port 80 only. That is far enough for certbot to answer
One-time setup on the server. It is in two stages because of a chicken and egg: the challenge.
the real config names a certificate, and nginx refuses to load a config whose
certificate does not exist yet - so nginx first comes up on port 80 only, just
far enough for certbot to answer the challenge there.
```sh ```sh
sudo mkdir -p /var/www/brevyscribe /var/www/certbot sudo mkdir -p /var/www/brevyscribe /var/www/certbot
sudo chown "$USER" /var/www/brevyscribe sudo chown "$USER" /var/www/brevyscribe
# Stage 1: HTTP only, so nginx starts without a certificate. # Stage 1: HTTP only, so that nginx starts without a certificate.
sudo tee /etc/nginx/sites-available/scribe.luxick.de >/dev/null <<'EOF' sudo tee /etc/nginx/sites-available/scribe.luxick.de >/dev/null <<'EOF'
server { server {
listen 80; listen 80;
@@ -62,132 +93,46 @@ EOF
sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d scribe.luxick.de sudo certbot certonly --webroot -w /var/www/certbot -d scribe.luxick.de
# Stage 2: the real config, now that the certificate is on disk. # Stage 2: the real configuration, now that the certificate is on disk.
sudo cp deploy/nginx-scribe.luxick.de.conf /etc/nginx/sites-available/scribe.luxick.de sudo cp deploy/nginx-scribe.luxick.de.conf /etc/nginx/sites-available/scribe.luxick.de
sudo nginx -t && sudo systemctl reload nginx sudo nginx -t && sudo systemctl reload nginx
``` ```
`certonly --webroot` rather than `--nginx`: the config already carries its own Use `certonly --webroot`, not `--nginx`. The configuration in the repository
redirect and TLS block, and the nginx plugin would rewrite the installed file, carries its own redirect and TLS block. The nginx plugin rewrites the installed
leaving it drifted from the one in the repo. The port 80 block keeps its file, and the installed file then drifts away from the one in the repository.
`acme-challenge` location for exactly this reason, so renewals go on working The port 80 block keeps its `acme-challenge` location for this reason, so
unattended - but certbot's timer will not reload nginx by itself, so drop a renewals continue to work without your attention.
one-line hook in `/etc/letsencrypt/renewal-hooks/deploy/` that runs
`systemctl reload nginx`, or a renewed certificate will not be served until the
next restart.
Then every deploy is one command from a checkout on your own machine - it runs CAUTION: The timer of certbot does not reload nginx. Put a one-line hook that
the tests, builds, and rsyncs `dist/` over. The server needs no Node. runs `systemctl reload nginx` in `/etc/letsencrypt/renewal-hooks/deploy/`.
Without this hook, the server serves a renewed certificate only after the next
restart.
Each deploy is then one command from a checkout on your own machine. The
command runs the tests, builds the app and copies `dist/` to the server with
rsync.
```sh ```sh
./deploy/deploy.sh ./deploy/deploy.sh
``` ```
`--delete` is deliberate: asset filenames are content-hashed, so without it old The script uses `--delete` on purpose. The asset filenames contain a hash of
bundles would pile up forever. There is no state on the server and nothing to the content, so old bundles collect on the server without this flag. There is
back up; every roster lives in the browser's `localStorage`. no state on the server and nothing to back up. Every roster stays in the
`localStorage` of the browser.
`vite.config.js` sets `base: "/"`, because this is served at a domain root - `index.html` loads Noto Sans from Google Fonts, so a page load contacts
upstream sets `/fancyscribe` for GitHub Pages. If you ever serve it from a `fonts.googleapis.com`. If you do not want this request, delete the `<link>`
subpath, that is the setting to change. tags. Then host the font next to the fonts in `public/fonts/`.
Note that `index.html` loads Noto Sans from Google Fonts, so a page load reaches
out to `fonts.googleapis.com`. If you would rather it did not, drop the
`<link>` tags and self-host the font next to the ones already in `public/fonts/`.
## How the transforms fit in
```
upload/.rosz -> unzip -> DOMParser -> applyTransforms -> Create40kRoster* -> render
^
src/transforms/index.js
```
`src/App.jsx` runs them between parsing the XML and building the roster, so
flipping a toggle rebuilds from the original XML with no re-upload. That is also
why saved rosters hold the raw roster XML rather than the parsed object.
Which abilities get stripped and split is configured in
[`src/transforms/config.js`](src/transforms/config.js) - add to
`abilitiesToStrip` and `abilitiesToConvert` as you meet more of them.
## The test suite
The transforms began as three standalone Python scripts (`merge_duplicate_units`,
`remove_leader_abilities`, `convert_choice_abilities`) that rewrote the `.ros`
file before it was uploaded to FancyScribe. They are gone now, but they are what
the tests measure against.
`src/transforms/__fixtures__/` holds four 11th-edition rosters plus, for each,
the output those scripts produced: `<name>.{merge,strip,convert,all}.ros`. The
suite runs each transform over the input and asserts it produces the same roster.
That is worth more than a snapshot of the current code, because the expected
output came from a **different implementation** that was verified by actually
printing the cards.
That property is easy to destroy and hard to notice: regenerate a fixture from
the JS and the test compares the code against itself, passing no matter what it
does. So `scripts/update-fixtures.mjs` will not overwrite an existing fixture
without `--force`. Use it when you add a new example roster, and read the diff:
```sh
# drop a new roster in src/transforms/__fixtures__/, then
npm run fixtures
```
Comparison is structural rather than textual. The scripts edited the file as text
to keep its formatting byte for byte; these transforms build DOM nodes, so
attribute order and whitespace legitimately differ. Generated `id`/`typeId`
values are compared as *tokens*, so what has to match is that ids are shared and
distinct in the same pattern, not that both implementations hash alike.
`integration.test.js` goes further and pushes the transformed document through
the real roster parser, checking what lands on the card: one Skitarii card
carrying both weapons, the data-tether ability travelling with the model that
brings it, no Support ability left, and each Canticle its own row.
One wrinkle: the tests run under jsdom, which does not implement scoped selectors
the way browsers do. `force.querySelectorAll("force>selections>…")` finds nothing
there, where a browser matches the selector against the whole tree and then keeps
the descendants of `force` - so under jsdom the parser found no units at all. The
two call sites that relied on it now say `:scope>…`, which means the same thing in
a browser and works in both.
## Staying current with upstream
FancyScribe is actively developed - 11th edition support landed recently - and
this fork touches little of it. `upstream` is wired up:
```sh
git fetch upstream
git merge upstream/main
```
Conflicts should be confined to `src/App.jsx`, `index.html`, `vite.config.js` and
`package.json`. `src/transforms/` is entirely new and will never conflict.
The fork also hides three things upstream shows, because a generic datasheet has
nothing to say with them: the roster overview card and its charts, the unit
composition, and the per-unit points cost. Those decisions live in
[`src/fork.js`](src/fork.js) - another file upstream does not have - and
`src/10th/Roster.jsx` reaches for them on as few lines as possible:
- it imports `ShortSummaryTable` from `../fork` instead of `./ShortSummaryTable`,
a one-line change that leaves the render site untouched. The upstream
component is still in the tree, unused, so its future diffs keep applying.
- `hideModelCount` is pinned to `HIDE_UNIT_COMPOSITION` instead of being a piece
of checkbox state.
Only the two checkboxes and the `pts` span are deleted outright, so a merge that
touches them will say so rather than quietly bringing them back.
## Credit ## Credit
All the hard parts - the parsing, the card layout, the print CSS - are The difficult parts are the work of
[Nils Ueter's](https://github.com/NilsUeter/fancyscribe), with parsing logic [Nils Ueter](https://github.com/NilsUeter/fancyscribe): the parser, the card
descended in turn from layout and the print CSS. The parsing logic comes in turn from
[PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no [PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no
licence file, so treat this fork as a private, personal deployment rather than license file. Treat this fork as a private, personal deployment, not as
something to redistribute. something that you redistribute.
+1713
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+127 -4
View File
@@ -19,7 +19,11 @@ import { Arrow, wavyLine } from "../assets/icons";
import { Weapons, hasDifferentProfiles } from "./Weapons"; import { Weapons, hasDifferentProfiles } from "./Weapons";
import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB
import { ImgEditor } from "./ImgEditor"; import { ImgEditor } from "./ImgEditor";
import { trySettingLocalStorage } from "../helpers/useLocalStorage"; import {
trySettingLocalStorage,
useLocalStorage,
} from "../helpers/useLocalStorage";
import { useLore } from "../helpers/useLore";
import { HIDE_UNIT_COMPOSITION, ShortSummaryTable } from "../fork"; import { HIDE_UNIT_COMPOSITION, ShortSummaryTable } from "../fork";
const getShortSummarySubtitle = (force) => { const getShortSummarySubtitle = (force) => {
@@ -46,6 +50,7 @@ export const Roster = ({
onePerPage, onePerPage,
colorUserChoice, colorUserChoice,
primaryColor, primaryColor,
showLore,
}) => { }) => {
if (!roster) { if (!roster) {
return null; return null;
@@ -70,6 +75,7 @@ export const Roster = ({
force={force} force={force}
onePerPage={onePerPage} onePerPage={onePerPage}
colorUserChoice={colorUserChoice} colorUserChoice={colorUserChoice}
showLore={showLore}
/> />
</React.Fragment> </React.Fragment>
))} ))}
@@ -77,7 +83,7 @@ export const Roster = ({
); );
}; };
const Force = ({ force, onePerPage, colorUserChoice }) => { const Force = ({ force, onePerPage, colorUserChoice, showLore }) => {
const { units, factionRules, rules, catalog } = force; const { units, factionRules, rules, catalog } = force;
const mergedRules = new Map([...factionRules, ...rules]); const mergedRules = new Map([...factionRules, ...rules]);
@@ -173,6 +179,7 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
onePerPage={onePerPage} onePerPage={onePerPage}
forceRules={rules} forceRules={rules}
colorUserChoice={colorUserChoice} colorUserChoice={colorUserChoice}
showLore={showLore}
/> />
</div> </div>
))} ))}
@@ -181,7 +188,14 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
); );
}; };
const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => { const Unit = ({
unit,
catalog,
onePerPage,
forceRules,
colorUserChoice,
showLore,
}) => {
const [hide, setHide] = useState(false); const [hide, setHide] = useState(false);
const hideModelCount = HIDE_UNIT_COMPOSITION; const hideModelCount = HIDE_UNIT_COMPOSITION;
const uploadRef = useRef(); const uploadRef = useRef();
@@ -201,6 +215,38 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
const hasImage = image && image !== "undefined"; const hasImage = image && image !== "undefined";
const [bgRemoved, setBgRemoved] = useState(false); const [bgRemoved, setBgRemoved] = useState(false);
// Flavour text, as the official cards print it beside the model image. The
// export is matched on the unit name, and whatever it comes back with can be
// edited in place - the name match is a heuristic, and a few names carry
// lore for more than one faction. An empty edit falls back to the export.
const lore = useLore();
const [loreOverride, setLoreOverride] = useLocalStorage(`lore_${name}`);
const loreText =
loreOverride && loreOverride !== "undefined"
? loreOverride
: lore?.lookup(name, catalog);
const hasLore = showLore && Boolean(loreText);
// The panel is positioned absolutely, so a long legend cannot push the header
// taller by itself and the last lines would be clipped - the longest entry in
// the export overruns a 15rem header at any width below about 1300px. Measure
// the text instead and let the header grow. Observed rather than measured
// once, because the wrap changes with the card width.
const loreRef = useRef(null);
const [loreHeight, setLoreHeight] = useState(0);
// hasLore is what mounts the observed element, so the effect has to re-run on
// it; a ref is not a reactive value, so the rule cannot see that.
// biome-ignore lint/correctness/useExhaustiveDependencies: see above
useEffect(() => {
const element = loreRef.current;
if (!element) return;
const observer = new ResizeObserver(() =>
setLoreHeight(element.scrollHeight),
);
observer.observe(element);
return () => observer.disconnect();
}, [hasLore]);
const weapons = [...meleeWeapons, ...rangedWeapons]; const weapons = [...meleeWeapons, ...rangedWeapons];
const weaponDescriptions = weapons const weaponDescriptions = weapons
@@ -332,6 +378,8 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
<div <div
className="min-h-[15rem]" className="min-h-[15rem]"
style={{ style={{
// 15rem unless the legend needs more; 44px is the panel's padding.
minHeight: hasLore ? `max(15rem, ${loreHeight + 44}px)` : undefined,
paddingTop: 24, paddingTop: 24,
paddingBottom: 4, paddingBottom: 4,
background: background:
@@ -430,20 +478,95 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
</div> </div>
</div> </div>
</div> </div>
{hasLore && (
<>
{/* Wider than the text it sits behind, so the model image fades
into the dark rather than ending at a hard edge. */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
right: 0, right: 0,
top: 0, top: 0,
bottom: 0,
width: "40%",
zIndex: 101,
pointerEvents: "none",
background:
"linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.55) 40%, rgba(0,0,0,.7) 100%)",
}}
/>
<div
style={{
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "29%",
zIndex: 102,
display: "flex",
alignItems: "center",
padding: "30px 14px 14px 6px",
overflow: "hidden",
}}
>
{/* Editable in place: see the note on `loreText` above. */}
<div
ref={loreRef}
contentEditable
suppressContentEditableWarning
spellCheck={false}
title="Click to correct this text. Clearing it restores the text from Lore.csv."
onBlur={(e) =>
setLoreOverride(e.currentTarget.innerText.trim())
}
style={{
fontStyle: "italic",
// index.css turns font synthesis off globally, so if the
// italic face fails to load this would print upright. Let
// this one element fall back to a slanted upright face.
fontSynthesis: "style",
fontSize: "1rem",
lineHeight: 1.32,
textShadow: "0 1px 2px rgba(0,0,0,.6)",
outline: "none",
}}
>
{loreText}
</div>
</div>
</>
)}
<div
style={{
position: "absolute",
// The image gives up its right-hand third to the lore panel, which
// is where the official cards put the flavour text. Cards without
// lore keep the full-width image they have always had.
right: hasLore ? "29%" : 0,
top: 0,
height: "100%", height: "100%",
bottom: 0, bottom: 0,
width: "60%", width: hasLore ? "40%" : "60%",
zIndex: 100, zIndex: 100,
overflow: "hidden", overflow: "hidden",
}} }}
> >
{hasImage && <ImgEditor image={image} name={name} />} {hasImage && <ImgEditor image={image} name={name} />}
<div className="absolute right-[1px] top-[3px] flex items-center gap-1.5"> <div className="absolute right-[1px] top-[3px] flex items-center gap-1.5">
{showLore && lore && !loreText && (
<button
type="button"
className="button print-display-none border-none bg-[#f0f0f0e6] hover:bg-[#f0f0f0]"
style={{
padding: "1px 4px",
fontSize: "0.8rem",
}}
onClick={() => setLoreOverride(`Lore for ${name}.`)}
title="Lore.csv has no entry under this name. Add the text by hand."
>
Add lore
</button>
)}
{hasImage && !bgRemoved && ( {hasImage && !bgRemoved && (
<button <button
type="button" type="button"
+18
View File
@@ -84,6 +84,7 @@ function App() {
const [roster, setRoster] = useState(); const [roster, setRoster] = useState();
const [edition, setEdition] = useState(10); // [9, 10, 11] const [edition, setEdition] = useState(10); // [9, 10, 11]
const [onePerPage, setOnePerPage] = useState(false); const [onePerPage, setOnePerPage] = useState(false);
const [showLore, setShowLore] = useState(true);
const [primaryColor, setPrimaryColor] = useState("#536766"); const [primaryColor, setPrimaryColor] = useState("#536766");
const [colorUserChoice, setColorUserChoice] = useState(false); const [colorUserChoice, setColorUserChoice] = useState(false);
const uploadRef = useRef(); const uploadRef = useRef();
@@ -419,6 +420,22 @@ function App() {
One Datacard per Page when Printing One Datacard per Page when Printing
</span> </span>
</label> </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 }}> <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<label style={{ display: "flex", alignItems: "center", gap: 4 }}> <label style={{ display: "flex", alignItems: "center", gap: 4 }}>
<input <input
@@ -464,6 +481,7 @@ function App() {
onePerPage={onePerPage} onePerPage={onePerPage}
colorUserChoice={colorUserChoice} colorUserChoice={colorUserChoice}
primaryColor={primaryColor} primaryColor={primaryColor}
showLore={showLore}
/> />
)} )}
+158
View File
@@ -0,0 +1,158 @@
// Lookup of the flavour text that official datasheets print to the right of the
// model image. The data lives in `public/Lore.csv`, a pipe-delimited export of
// `name|legend` (plus an optional `faction` column, see `buildLoreIndex`).
//
// Nothing about roster names is reliable enough for an exact lookup: the same
// unit is spelled "Tech-priest Dominus" in one place and "Tech-Priest Dominus"
// in another, a roster may name a unit in the plural where the export uses the
// singular ("Myphitic Blight-haulers" / "Myphitic Blight-hauler"), and faction
// catalogues prefix names that the export does not ("Thousand Sons Chaos
// Spawn" / "Chaos Spawn"). So the index is keyed by a normalised form and
// consulted through three widening attempts.
/**
* Case, accents, curly quotes and punctuation all vary between the export and
* the roster, and none of them carry meaning here, so collapse the lot.
*/
export const normalizeName = (name) =>
String(name ?? "")
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
/**
* Crude singularisation of the last word, which is the only place a roster and
* the export tend to disagree on number. Deliberately not a real stemmer: it
* runs over both sides of the comparison, so it only has to be consistent, not
* correct.
*/
const singularize = (word) => {
if (word.length < 4) return word;
if (word.endsWith("ies")) return `${word.slice(0, -3)}y`;
if (/(?:ss|sh|ch|x|z)es$/.test(word)) return word.slice(0, -2);
if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
return word;
};
const stemKey = (normalized) => {
const words = normalized.split(" ");
if (!words.length) return normalized;
words[words.length - 1] = singularize(words[words.length - 1]);
return words.join(" ");
};
const splitLines = (text) =>
String(text ?? "")
.replace(/^\ufeff/, "")
.split(/\r?\n/);
/**
* Parses the export into `{ name, legend, faction }` rows. Rows without a
* legend are dropped - the export carries a few hundred of them, one per unit
* whose lore has not been transcribed yet, and they would otherwise shadow a
* usable entry for the same name.
*
* Columns are located by the header line, so adding a `faction` column (see
* `pickEntry`) does not need a code change.
*/
export const parseLore = (text) => {
const lines = splitLines(text).filter((line) => line.trim());
if (!lines.length) return [];
const header = lines[0].split("|").map((h) => h.trim().toLowerCase());
const nameCol = header.indexOf("name");
const legendCol = header.indexOf("legend");
const factionCol = header.indexOf("faction");
if (nameCol === -1 || legendCol === -1) return [];
const rows = [];
for (const line of lines.slice(1)) {
const fields = line.split("|");
const name = fields[nameCol]?.trim();
const legend = fields[legendCol]?.trim();
if (!name || !legend) continue;
rows.push({
name,
legend,
faction: factionCol === -1 ? "" : (fields[factionCol]?.trim() ?? ""),
});
}
return rows;
};
/**
* Several names carry more than one legend - either the same unit reworded
* between editions, or a genuinely different unit sharing a name across
* factions (Chaos Daemons and Death Guard both field Plaguebearers; three
* different armies field a Ministorum Priest).
*
* Given a `faction` column in the export, that ambiguity is resolvable and we
* prefer the entry whose faction matches the card. Without one - which is the
* case for today's export - fall back to the longest legend. That is not
* always the *right* variant, but it is deterministic, and the competing
* variants are near-identical rewrites in all but a handful of cases.
*/
const pickEntry = (entries, faction) => {
const wanted = normalizeName(faction);
if (wanted) {
const match = entries.find((entry) => {
const entryFaction = normalizeName(entry.faction);
return (
entryFaction &&
(entryFaction === wanted ||
wanted.includes(entryFaction) ||
entryFaction.includes(wanted))
);
});
if (match) return match;
}
return entries.reduce((best, entry) =>
entry.legend.length > best.legend.length ? entry : best,
);
};
/**
* Builds the lookup. `lookup(name, faction)` returns the legend string, or
* `undefined` when the unit has no entry.
*/
export const buildLoreIndex = (text) => {
const exact = new Map();
const stems = new Map();
for (const row of parseLore(text)) {
const key = normalizeName(row.name);
if (!key) continue;
if (!exact.has(key)) exact.set(key, []);
exact.get(key).push(row);
const stem = stemKey(key);
if (!stems.has(stem)) stems.set(stem, []);
stems.get(stem).push(row);
}
// Multi-word keys, longest first, for the trailing-name pass below. Single
// word keys are excluded: "Guard" or "Rangers" would match half the export.
const suffixKeys = [...stems.keys()]
.filter((key) => key.includes(" "))
.sort((a, b) => b.length - a.length);
const lookup = (name, faction) => {
const normalized = normalizeName(name);
if (!normalized) return undefined;
const entries =
exact.get(normalized) ??
stems.get(stemKey(normalized)) ??
// Last resort: the roster name ends with a name we know, which is how
// faction-prefixed datasheets ("Thousand Sons Chaos Spawn") arrive.
stems.get(
suffixKeys.find((key) => stemKey(normalized).endsWith(` ${key}`)),
);
return entries ? pickEntry(entries, faction).legend : undefined;
};
return { lookup, size: exact.size };
};
+125
View File
@@ -0,0 +1,125 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildLoreIndex, normalizeName, parseLore } from "./lore";
const csv = (...lines) => `name|legend\n${lines.join("\n")}\n`;
describe("normalizeName", () => {
it("collapses the casing, punctuation and quote style that vary between exports", () => {
expect(normalizeName("Tech-priest Dominus")).toBe("tech priest dominus");
expect(normalizeName("Tech-Priest Dominus")).toBe("tech priest dominus");
expect(normalizeName("Khornes Hounds")).toBe("khorne s hounds");
expect(normalizeName("Khorne's Hounds")).toBe("khorne s hounds");
});
it("survives missing input", () => {
expect(normalizeName(undefined)).toBe("");
expect(normalizeName(null)).toBe("");
});
});
describe("parseLore", () => {
it("reads the pipe-delimited export, BOM and CRLF included", () => {
const rows = parseLore("name|legend\r\nCustodian Guard|Stalwart.\r\n");
expect(rows).toEqual([
{ name: "Custodian Guard", legend: "Stalwart.", faction: "" },
]);
});
it("drops rows whose legend has not been filled in", () => {
const rows = parseLore(csv("Webway Gate|", "Bonesinger|Sings to bone."));
expect(rows.map((row) => row.name)).toEqual(["Bonesinger"]);
});
it("locates columns by header, so an added faction column just works", () => {
const rows = parseLore(
"legend|faction|name\nSings to bone.|Aeldari|Bonesinger\n",
);
expect(rows).toEqual([
{ name: "Bonesinger", legend: "Sings to bone.", faction: "Aeldari" },
]);
});
it("returns nothing for junk rather than throwing", () => {
expect(parseLore("")).toEqual([]);
expect(parseLore("unit;text\nfoo;bar")).toEqual([]);
});
});
describe("buildLoreIndex", () => {
it("matches names that differ only in case or punctuation", () => {
const { lookup } = buildLoreIndex(csv("Tech-priest Dominus|Theocrat."));
expect(lookup("Tech-Priest Dominus")).toBe("Theocrat.");
});
it("matches a plural roster name against a singular entry", () => {
const { lookup } = buildLoreIndex(csv("Myphitic Blight-hauler|Belching."));
expect(lookup("Myphitic Blight-haulers")).toBe("Belching.");
});
it("matches a singular roster name against a plural entry", () => {
const { lookup } = buildLoreIndex(csv("Plaguebearers|Foot soldiers."));
expect(lookup("Plaguebearer")).toBe("Foot soldiers.");
});
it("strips a faction prefix the export does not carry", () => {
const { lookup } = buildLoreIndex(csv("Chaos Spawn|Roiling flesh."));
expect(lookup("Thousand Sons Chaos Spawn")).toBe("Roiling flesh.");
});
it("will not match on a single trailing word", () => {
const { lookup } = buildLoreIndex(csv("Guard|Some other unit entirely."));
expect(lookup("Custodian Guard")).toBeUndefined();
});
it("prefers the entry whose faction matches the card", () => {
const { lookup } = buildLoreIndex(
"name|legend|faction\n" +
"Plaguebearers|Daemon version, which is the longer of the two.|Chaos Daemons\n" +
"Plaguebearers|Guard version.|Death Guard\n",
);
expect(lookup("Plaguebearers", "Death Guard")).toBe("Guard version.");
expect(lookup("Plaguebearers", "Chaos Daemons")).toBe(
"Daemon version, which is the longer of the two.",
);
});
it("falls back to the longest legend when the faction cannot decide it", () => {
const { lookup } = buildLoreIndex(
csv("Servitors|Short.", "Servitors|The longer, fuller entry."),
);
expect(lookup("Servitors")).toBe("The longer, fuller entry.");
expect(lookup("Servitors", "Adeptus Mechanicus")).toBe(
"The longer, fuller entry.",
);
});
it("returns undefined for a unit the export does not cover", () => {
const { lookup } = buildLoreIndex(csv("Bonesinger|Sings to bone."));
expect(lookup("Rein and Raus")).toBeUndefined();
expect(lookup("")).toBeUndefined();
});
});
// Guards the shipped export itself: a re-export that changes the delimiter or
// the header names would otherwise fail silently, every card simply losing its
// flavour text.
describe("public/Lore.csv", () => {
const index = buildLoreIndex(readFileSync("public/Lore.csv", "utf8"));
it("parses into a usable number of entries", () => {
expect(index.size).toBeGreaterThan(1000);
});
it("covers the units in the bundled example rosters", () => {
for (const name of [
"Custodian Guard",
"Bladeguard Veteran Squad",
"Plague Marines",
"Myphitic Blight-haulers",
"Thousand Sons Chaos Spawn",
]) {
expect(index.lookup(name), name).toBeTruthy();
}
});
});
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { buildLoreIndex } from "./lore";
// `public/Lore.csv` is ~400KB, so it is fetched once, lazily, and shared by
// every card rather than bundled into the main chunk. The promise is cached at
// module scope: a roster renders 20-odd Units, and they must not each kick off
// their own request.
let pending;
const loadLore = () => {
if (!pending) {
pending = fetch("Lore.csv")
.then((response) => {
if (!response.ok) throw new Error(`Lore.csv: ${response.status}`);
return response.text();
})
.then(buildLoreIndex)
.catch((error) => {
// A missing or unreadable export is not worth failing a card over -
// the datasheet simply prints without its flavour text.
console.error(error);
return { lookup: () => undefined, size: 0 };
});
}
return pending;
};
/**
* Resolves to the lore index, or `null` until it has loaded.
*/
export const useLore = () => {
const [index, setIndex] = useState(null);
useEffect(() => {
let live = true;
loadLore().then((loaded) => live && setIndex(loaded));
return () => {
live = false;
};
}, []);
return index;
};
+8
View File
@@ -40,6 +40,14 @@
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2"); src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
font-weight: 400; font-weight: 400;
} }
/* Datasheet lore text. Without a real italic face there would be none at all:
font-synthesis is off below, so the browser may not slant an upright one. */
@font-face {
font-family: "ConduitITCStd";
src: url("/fonts/ConduitITCStd Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
}
* { * {
box-sizing: border-box; box-sizing: border-box;