Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions specs/001-pathway-pdfs/research.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,14 @@ synthetic documents, on this host under load.
letters share) to the top of the letters standing on it, the label taken at its tallest
line, since one label is one text layer at one size and a dash set apart on a line of
its own would otherwise pass for small letters (five illustrations have such lines).
The cap height is taken as a font's of the same cap height (0.71 of its size, Roboto's
0.711, Arial's 0.716). Release 98's 221 illustrations, every one with labels, none
A letter's holes (the counters of B, O, d) lie within its outline and are left out
before the vote, or a short label's holes could set its baseline. A line with
ascenders (l, d, G) reads a little tall, by up to the ascender's height over the
capitals' (about 8% in Roboto), which errs large; a long name, which each
illustration's smallest label is, reads within a few percent. The cap height is taken
as a font's of the same cap height (0.71 of its size, Roboto's 0.711, Arial's 0.716).
A label transformed, or in a transformed group other than the render service's
viewport, is not read and leaves the figure unmeasured. Release 98's 221 illustrations, every one with labels, none
transformed:
- every label's cap height is 8.49–8.94 of the file's units, a font of about 12 px,
as the diagrams' labels are;
Expand All @@ -265,10 +271,9 @@ synthetic documents, on this host under load.
- **Decision** (user, 3 Nov 2026), for the reader, who reads the book on a screen: most
screens are wider than tall, so a wide figure on a landscape page fills more of one;
no page makes most diagrams' labels readable unzoomed, so every figure links to its
interactive form on the website (FR-003), where they are. The rule is two constants
in `src/layout/figure.ts`, `FLOOR_PT` (the report's threshold) and the comparison in
`place()`, so curators who prefer another, such as portrait unless the labels would
fall below a size, change it there. `measure()` reads an illustration's lettering
interactive form on the website (FR-003), where they are. The rule is in `src/layout/figure.ts`: the constant `FLOOR_PT` (the report's threshold)
and the orientation chosen in `place()`, so curators who prefer another, such as
portrait unless the labels would fall below a size, change it there. `measure()` reads an illustration's lettering
(`src/layout/lettering.ts`) as it reads text's sizes, the smaller of the two counting;
a label whose outlines are not read (a relative command, an arc, a viewport that is
not a plain scale) counts as unread, so the figure is reported as unmeasured.
Expand Down
3 changes: 2 additions & 1 deletion specs/001-pathway-pdfs/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ for WebsiteAngular to ship it (T042).
- [x] T008 [P] Measure the diagrams' widths (FR-016's floor, research R4). From the release's diagram files, take each diagram's width in SVG pixels at the website's 12 px node labels. Count how many fit at 5 pt in portrait (about 1,155 px), in landscape (about 1,750 px), and in neither. Record the counts in research R4. If more than 5% fit neither, raise FR-016's floor with the user. Measure the illustrations' lettering too (their text is outlines, so the layout cannot read it and reports them as unmeasured, T018): the smallest letter height in a sample of release 98's EHLDs, rasterised, and the scale that keeps it at 5 pt; then give `place()` a rule for them.

**Done** (research R4) — #106. Release 98's 1,170 human diagrams, drawn by the render service from the release's own files: only 80 (7%) can print their 12 px labels at 5 pt on any page, the median at 2.1 pt; today's book, which has no floor, prints a median of about 1.85 pt. Raised with the user, who chose, for the reader on a screen: each figure as large as the page allows, portrait or a landscape page of its own where that is larger (1,060 diagrams and all 221 illustrations landscape), never split, linked to its interactive form on the website (FR-003, amended), and listed in the run report below 1 pt (100 diagrams). FR-016 and the data model amended. The illustrations' lettering, outlines, is read from their `PATHWAY TEXT` paths at the render service's viewport scale (`src/layout/lettering.ts`): every label about a 12 px font, 13.9–14.8 px in the render's frame, one at 7.2 px; the render's SVG and the file agreed. Tests: 11 for the lettering, 6 for the figure's links, the placement tests rewritten for the rule. Each of these mutants made one fail: the tallest line not taken, glyphs off the baseline counted, the viewport ignored or any matrix taken, relative commands read as absolute, subpaths merged, no cap-height ratio, an unreadable label skipped or its count dropped, the lettering ignored or used only without text, never landscape, landscape on a tie, the threshold back at 5 pt, the report at the portrait size, no link, no selection, a reaction to the Browser, the section linking the event's page. The link was printed and read back: one annotation over the figure on its landscape page; without the link's `inline-block`, two. From #105's review: `serve`'s bind names its second caller, the analysis gateway (contracts/cli.md, research R9).
- **From #106's review (#107):** the lettering reader left a letter's holes in, so on a short label a counter could win the baseline vote (BBB read 62% small; release 98's smallest labels, long names, were unaffected, and R4's numbers stand, recounted); read an exponent as a command; joined a subpath begun after a close without a move to the one before; took a command with the wrong count of numbers; and read a label transformed, or in a transformed group, at full size. Holes are now left out, an exponent (either case) is a number, a subpath after a close starts at the last one's start, each command's count is checked, and a transformed label is unread, the groups followed through the markup in one pass of its tags. An ancestor's illustration standing in for an event now links with the event selected. Stale wording of the 5 pt floor is gone from the code's comments and R4. Each was seen failing first.

- [ ] T009 [P] Measure the analysis service's answers (research R6), on a private instance with a submitted analysis, never production. For each case, record the status and body in research R6:
- a good token;
Expand Down Expand Up @@ -223,7 +224,7 @@ for WebsiteAngular to ship it (T042).
- each section's parts;
- the contents list, with fixed-width placeholders for page numbers.
- **Style:** the theme's CSS, and `@font-face` rules for the local fonts.
- **Figures:** inlined, sized to FR-016's floor (5 pt labels, or a landscape page of its own), with `break-inside: avoid`.
- **Figures:** inlined, sized to FR-016's floor, which T008 later replaced (5 pt labels, or a landscape page of its own), with `break-inside: avoid`.
- **Links:** as FR-003 says, with a website base URL setting.
- **Separation:** space, type and thin rules only; no boxes or borders (FR-016).
- **Book mode:** internal links as placeholder URIs (`https://anchor.invalid/<id>`), and a 1 px probe link inside each target (research R4).
Expand Down
4 changes: 2 additions & 2 deletions src/layout/figure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,8 @@ export function figureHtml(

/**
* The cover's figure: a single pathway's diagram fitted to the cover's box. It is a
* picture of the pathway, not where it is read (its section has it at a legible size), so
* the floor does not apply.
* picture of the pathway, not where it is read (its section prints it at its largest), so
* it is not reported against FLOOR_PT.
*/
export function coverFigureHtml(svg: string, label: string): string {
const { width, height } = measure(svg);
Expand Down
7 changes: 5 additions & 2 deletions src/layout/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,15 @@ export class Links {
/**
* An event's figure in its interactive form on the website, where its labels can be read:
* a pathway's diagram or illustration in the Pathway Browser, the event selected where it
* is drawn in an ancestor's; a reaction's page, which opens it in its pathway there. The
* is drawn in an ancestor's, or an ancestor's illustration stands in for it; a reaction's page, which opens it in its pathway there. The
* Browser's address is its older form, which the website still reads (`#/` and `SEL`).
*/
figure(figure: Figure, stId: string): string {
if (figure.kind === 'reaction') return this.event(stId);
const select = figure.kind === 'ancestor' ? `&SEL=${encodeURIComponent(stId)}` : '';
// drawn in an ancestor's diagram, or its illustration standing in: the event selected
const drawnElsewhere =
figure.kind === 'ancestor' || (figure.kind === 'ehld' && figure.unselectedFor !== undefined);
const select = drawnElsewhere ? `&SEL=${encodeURIComponent(stId)}` : '';
return this.website(`/PathwayBrowser/#/${encodeURIComponent(figure.stId)}${select}`);
}

Expand Down
147 changes: 87 additions & 60 deletions src/layout/lettering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,69 +10,79 @@ export const CAP_HEIGHT = 0.71;
/** A box: left, top, right, bottom. */
type Box = [number, number, number, number];

const TOKEN = /[A-Za-z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/g;
const TOKEN = /[A-Za-z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g;

/** The numbers each absolute command takes, a whole number of times. */
const ARITY: Readonly<Record<string, number>> = { M: 2, L: 2, T: 2, H: 1, V: 1, S: 4, Q: 4, C: 6 };

/**
* Each subpath's box, from its points and control points (which may overshoot a curve
* slightly: that errs large, by less than a stroke). Only absolute commands are read, as the
* release's illustrations use: a relative command or an arc gives null, not a guess.
* release's illustrations use: a relative command, an arc or a command with the wrong count
* of numbers gives null, not a guess.
*/
export function outlineBoxes(d: string): Box[] | null {
const boxes: Box[] = [];
// whether the last box is the subpath being drawn: a move begins another
// whether the last box is the subpath being drawn: a move or a close ends it
let open = false;
let x = 0;
let y = 0;
// where the subpath began, which a close returns to
let startX = 0;
let startY = 0;
let command = '';
let numbers: number[] = [];
const point = (px: number, py: number) => {
if (!open) {
// a subpath begins at a move's point, or, begun without one, at the current point
if (command === 'M') [x, y] = [px, py];
[startX, startY] = [x, y];
boxes.push([x, y, x, y]);
open = true;
}
x = px;
y = py;
const box = open ? boxes.at(-1) : undefined;
if (box === undefined) boxes.push([px, py, px, py]);
else
boxes[boxes.length - 1] = [
Math.min(box[0], px),
Math.min(box[1], py),
Math.max(box[2], px),
Math.max(box[3], py),
];
open = true;
const box = boxes[boxes.length - 1] as Box;
boxes[boxes.length - 1] = [
Math.min(box[0], px),
Math.min(box[1], py),
Math.max(box[2], px),
Math.max(box[3], py),
];
};
const finish = (): boolean => {
switch (command) {
case '':
case 'Z':
return numbers.length === 0;
case 'H':
for (const n of numbers) point(n, y);
return numbers.length > 0;
case 'V':
for (const n of numbers) point(x, n);
return numbers.length > 0;
case 'M':
case 'L':
case 'C':
case 'S':
case 'Q':
case 'T':
for (let i = 0; i + 1 < numbers.length; i += 2) point(numbers[i] ?? 0, numbers[i + 1] ?? 0);
return numbers.length > 0 && numbers.length % 2 === 0;
default:
return false;
}
if (command === '' || command === 'Z') return numbers.length === 0;
const arity = ARITY[command];
if (arity === undefined || numbers.length === 0 || numbers.length % arity !== 0) return false;
if (command === 'H') for (const n of numbers) point(n, y);
else if (command === 'V') for (const n of numbers) point(x, n);
else for (let i = 0; i < numbers.length; i += 2) point(numbers[i] ?? 0, numbers[i + 1] ?? 0);
return true;
};
for (const [token] of d.matchAll(TOKEN)) {
if (/[A-Za-z]/.test(token)) {
if (/^[A-Za-z]$/.test(token)) {
if (!finish()) return null;
if (token === 'M') open = false;
if (token === 'Z') {
// the current point returns to the subpath's start, and the next one begins there
[x, y] = [startX, startY];
open = false;
}
command = token;
numbers = [];
} else numbers.push(Number(token));
}
return finish() ? boxes : null;
}

/** Whether one box lies within another: a letter's hole (the counter of B, O or d). */
const within = (inner: Box, outer: Box) =>
inner !== outer &&
inner[0] >= outer[0] &&
inner[1] >= outer[1] &&
inner[2] <= outer[2] &&
inner[3] <= outer[3];

/** A label's lines: letters whose boxes overlap in height are on one line. */
function lines(boxes: Box[]): Box[][] {
const found: { boxes: Box[]; top: number; bottom: number }[] = [];
Expand All @@ -90,12 +100,16 @@ function lines(boxes: Box[]): Box[][] {
/**
* A label's cap height: its tallest line's. One label is one text layer, at one size, so a
* line of punctuation alone (a dash set apart, which stands on no baseline of letters) does
* not make it smaller. A line's baseline is the bottom most of its letters share, to half a
* unit; its cap height runs from there to the top of the letters standing on it.
* not make it smaller. A letter's holes are left out first, so they do not vote for a
* baseline of their own. A line's baseline is the bottom most of its letters share, to half
* a unit; its cap height runs from there to the top of the letters standing on it, so a line
* with ascenders (l, d, G) reads a little tall, by up to the ascender's height over the
* capitals', which errs large.
*/
export function capHeight(boxes: Box[]): number | null {
let tallest: number | null = null;
for (const line of lines(boxes)) {
for (const all of lines(boxes)) {
const line = all.filter((b) => !all.some((outer) => within(b, outer)));
const bottoms = new Map<number, number>();
for (const b of line) {
const at = Math.round(b[3] * 2) / 2;
Expand All @@ -112,41 +126,54 @@ export function capHeight(boxes: Box[]): number | null {
return tallest;
}

/**
* The scale the render service draws the illustration at: its viewport's `matrix(a 0 0 a e
* f)`, which fits it to the figure's frame, or 1 where there is none. Anything else (a
* rotation, a skew, unequal scales, another form) gives null.
*/
function viewportScale(svg: string): number | null {
const at = svg.indexOf('svg-pan-zoom_viewport');
if (at < 0) return 1;
const start = svg.lastIndexOf('<', at);
const end = svg.indexOf('>', at);
const tag = svg.slice(start, end < 0 ? svg.length : end);
const matrix = /\stransform="matrix\(([^)]*)\)"/
.exec(tag)?.[1]
?.split(/[\s,]+/)
/** A plain scale from a `matrix(a 0 0 a e f)`, or null for anything else. */
function plainScale(transform: string | undefined): number | null {
const matrix = /^\s*matrix\(([^)]*)\)\s*$/
.exec(transform ?? '')?.[1]
?.trim()
.split(/[\s,]+/)
.map(Number);
if (matrix?.length !== 6) return null;
const [a, b, c, d] = matrix as [number, number, number, number];
return a > 0 && b === 0 && c === 0 && d === a ? a : null;
}

const ATTRIBUTE = (name: string) => new RegExp(`\\s${name}="([^"]*)"`);
const TRANSFORM = ATTRIBUTE('transform');
const CLASS = ATTRIBUTE('class');
const ID = ATTRIBUTE('id');
const D = ATTRIBUTE('d');

/**
* An illustration's lettering: its smallest label's size, in the SVG's pixels, as a font's of
* the same cap height, or null where it has none read; and how many label paths could not be
* read (outlines in a form not read here, or all of them where the viewport is not a plain
* scale), which the caller counts as unread labels. A transform on a label, or between it and
* the viewport, is not known here; release 98's illustrations have none.
* read, which the caller counts as unread labels. A label is not read when its outlines are
* in a form not read here, or when it, or a group it is in, is transformed (other than the
* render service's viewport, whose plain scale fits the illustration to the figure's frame
* and is applied; a viewport that is not a plain scale leaves every label inside it unread).
* The groups are followed through the markup in one pass of its tags.
*/
export function lettering(svg: string): { smallest: number | null; unread: number } {
const scale = viewportScale(svg);
let smallest: number | null = null;
let unread = 0;
for (const id of svg.matchAll(/\sid="PATHWAY TEXT[^"]*"/g)) {
const start = svg.lastIndexOf('<', id.index);
const end = svg.indexOf('>', id.index);
const d = /\sd="([^"]*)"/.exec(svg.slice(start, end < 0 ? svg.length : end))?.[1];
// for each open group: the scale it applies, or null where it transforms otherwise
const groups: (number | null)[] = [];
for (const [tag, close, name = '', attributes = ''] of svg.matchAll(
/<(\/?)(g|path)\b([^>]*)>/g
)) {
if (name === 'g') {
if (close) groups.pop();
else if (!attributes.trimEnd().endsWith('/')) {
const transform = TRANSFORM.exec(attributes)?.[1];
const viewport = /\bsvg-pan-zoom_viewport\b/.test(CLASS.exec(attributes)?.[1] ?? '');
groups.push(transform === undefined ? 1 : viewport ? plainScale(transform) : null);
}
continue;
}
if (close || !(ID.exec(attributes)?.[1] ?? '').startsWith('PATHWAY TEXT')) continue;
let scale: number | null = TRANSFORM.test(tag) ? null : 1;
for (const g of groups) scale = scale === null || g === null ? null : scale * g;
const d = D.exec(attributes)?.[1];
const boxes = d === undefined ? null : outlineBoxes(d);
const cap = boxes === null ? null : capHeight(boxes);
if (scale === null || cap === null || !(cap > 0)) {
Expand Down
4 changes: 2 additions & 2 deletions src/layout/section.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import { Links, attribute, text } from './html.ts';

/** What laying out sections reports, for the run report and the service's log. */
export interface Report {
/** Figures printed below the legibility floor even on a landscape page, by stable id. */
/** Figures whose labels print below FLOOR_PT even at their largest, by stable id. */
belowFloor: string[];
/** Figures with no label whose size can be read (illustrations), by stable id. */
/** Figures with no label whose size is read, or a label that cannot be read, by stable id. */
unmeasured: string[];
/** Sections whose figure was not given, by stable id. */
missing: string[];
Expand Down
Loading
Loading