docs: showcase documents and screenshots replace the README's sample picture

scripts/showcase/run.mjs builds three sample documents plus a review
variant as .odt and .docx through the app's own exporters in headless
Chromium, opens each through the file dialog and takes the README
screenshots; docs/showcase/ holds the results. The README gets a
light/dark hero picture and a gallery of four tiles.
This commit is contained in:
Steffen Becker committed 2026-09-06 19:07:13 +02:00
1 parent 91a68ba3b0
commit 406206c300
29 files changed
+4007 -2

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ npm run test:coverage # vitest + v8 coverage over src/ → coverage/index.html
npm run test:parity # render parity vs LibreOffice (tests/render-parity/README.md)
node scripts/make-thesaurus.mjs # re-vendor public/thesaurus/ from LibreOffice's MyThes data
node scripts/collect-licenses.mjs # regenerate public/licenses.txt after a dependency change
node scripts/showcase/run.mjs # rebuild docs/showcase/ (sample .odt/.docx + README screenshots); [regex] limits it
```
Tests live in `tests/` (outside `src/`, so `svelte-check` ignores them), jsdom via Vitest.
+16 -2
View File
@@ -35,8 +35,10 @@ EdenText is a web-based, powerful word processor for everything from quick notes
> listed under [Not yet implemented](CHANGELOG.md#not-yet-implemented) and
> [Known limitations](CHANGELOG.md#known-limitations).
[![EdenText editing a multi-column document](docs/screenshot.png)](https://edentext.app)
Sample File from https://sample-files.com/
<a href="https://edentext.app"><picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/showcase/thesis-dark.png">
<img src="docs/showcase/thesis.png" alt="EdenText editing a thesis: numbered chapters, formulas and a running header">
</picture></a>
## Features
@@ -60,6 +62,18 @@ Sample File from https://sample-files.com/
file; other browsers receive each save as a download, so turn on "Always ask
where to save" in their settings to pick the location
## Gallery
The documents behind these pictures are in [`docs/showcase/`](docs/showcase/) as `.odt`
and `.docx`, ready to open in EdenText, LibreOffice or Word. They are built by
`scripts/showcase/run.mjs`; the photographs are NASA's and the book is Lewis Carroll's,
both public domain.
| | |
|---|---|
| [![Tracked changes and comments](docs/showcase/thesis-review.png)](docs/showcase/thesis-review.png) **Review** — tracked changes, threaded comments in the margin, a draft watermark | [![A book on facing pages](docs/showcase/book-spread.png)](docs/showcase/book-spread.png) **Book** — A5, mirrored margins, chapters opening on a right-hand page, running heads |
| [![A newsletter in two columns](docs/showcase/newsletter.png)](docs/showcase/newsletter.png) **Newsletter** — columns, pictures with captions, a sidebar box | [![Tables with formulas](docs/showcase/newsletter-tables.png)](docs/showcase/newsletter-tables.png) **Tables** — table styles and spreadsheet-style formulas |
## Development
```bash
Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

File diff suppressed because it is too large. Load diff
+85
View File
@@ -0,0 +1,85 @@
// A book: Alice's Adventures in Wonderland (Lewis Carroll, 1865, public domain), A5 with
// mirrored margins, every chapter opening on a right-hand page, running heads that
// differ between left and right pages, and a contents page. Text from alice.txt.
import { builtinStyleSheet } from '../../src/lib/styles/styleSheet';
import { EMPTY_HF_SET, type HfSet } from '../../src/lib/storage/headerFooter';
import { T, P, BR, INDEX, HF, PAGE_NUMBER, ITALIC, type N, type Showcase } from './lib';
// `_like this_` is the plain-text edition's italic.
function runs(text: string): N[] {
return text.split('_').map((part, i) => part && T(part, ...(i % 2 ? [ITALIC] : []))).filter(Boolean) as N[];
}
function body(raw: string): N[] {
const out: N[] = [];
let first = true; // the paragraph after a heading takes no first-line indent
for (const block of raw.split(/\n\s*\n/)) {
const chapter = /^CHAPTER ([IVX]+)\.\n(.+)$/.exec(block.trim());
if (chapter) {
out.push(P({ styleName: 'Chapter Number', sectionBreak: true }, T(`Chapter ${chapter[1]}`)));
out.push({ type: 'heading', attrs: { level: 1 }, content: [T(chapter[2].trim())] });
first = true;
} else if (/^[*\s]+$/.test(block)) {
out.push(P({ textAlign: 'center', spaceBefore: 8, spaceAfter: 8 }, T('* * *')));
} else if (block.split('\n').every((l) => /^\s/.test(l))) {
const lines = block.split('\n').map((l) => l.trim());
out.push(P({ styleName: 'Verse' }, ...lines.flatMap((l, i) => [...(i ? [BR] : []), ...runs(l)])));
} else {
const text = block.split('\n').map((l) => l.trim()).join(' ');
out.push(P(first ? null : { indentFirst: 0.6 }, ...runs(text)));
first = false;
}
}
return out;
}
export async function build(): Promise<Showcase> {
const raw = await (await fetch('/scripts/showcase/alice.txt')).text();
const styles = builtinStyleSheet();
styles.paragraph['Standard'].text = { fontFamily: 'Liberation Serif', fontSizePt: 11 };
styles.paragraph['Standard'].para = { textAlign: 'justify', lineHeight: '1.25', spaceAfter: 0 };
styles.paragraph['Heading 1'].text = { fontFamily: 'Liberation Serif', fontSizePt: 20, bold: false, italic: true };
styles.paragraph['Heading 1'].para = { textAlign: 'center', spaceBefore: 0, spaceAfter: 30 };
styles.paragraph['Chapter Number'] = { name: 'Chapter Number', parent: 'Standard', next: 'Heading 1',
para: { textAlign: 'center', spaceBefore: 84, spaceAfter: 4 },
text: { caps: 'smallCaps', letterSpacingPt: 1.5 } };
styles.paragraph['Verse'] = { name: 'Verse', parent: 'Standard', next: 'Standard',
para: { textAlign: 'left', indent: 1.5, spaceBefore: 6, spaceAfter: 6 }, text: {} };
styles.paragraph['Front Matter Heading'] = { name: 'Front Matter Heading', parent: 'Heading 1', next: 'Standard',
para: { spaceBefore: 84, spaceAfter: 24 }, text: {} };
styles.paragraph['Title'].text = { fontFamily: 'Liberation Serif', fontSizePt: 26, bold: false };
styles.paragraph['Subtitle'].text = { fontFamily: 'Liberation Serif', fontSizePt: 14, bold: false, italic: true };
const doc: N = { type: 'doc', content: [
P({ styleName: 'Title', spaceBefore: 180, spaceAfter: 24 }, T('Alice’s Adventures in Wonderland')),
P({ styleName: 'Subtitle' }, T('by Lewis Carroll')),
P({ styleName: 'Front Matter Heading', breakBefore: 'page' }, T('Contents')),
INDEX('toc', '', { maxLevel: 1 }),
...body(raw),
] };
// Right-hand pages carry the chapter, left-hand pages the book; the number sits outside.
const right = HF({ tabStops: '11r' }, { type: 'chapterField', attrs: { level: 1, text: 'Down the Rabbit-Hole' } }, T('\t'), PAGE_NUMBER);
const left = HF({ tabStops: '11r' }, PAGE_NUMBER, T('\t'), T('Alice’s Adventures in Wonderland', ITALIC));
// A chapter opens on a right-hand page with no running head, its number at the foot.
const chapterSet: HfSet = { ...EMPTY_HF_SET, header: right, headerEven: left, differentOddEven: true, startsOn: 'odd',
differentFirstPage: true, headerFirst: null, footerFirst: HF({ textAlign: 'center' }, PAGE_NUMBER) };
const front: HfSet = { ...EMPTY_HF_SET };
const sections = [front, ...Array.from({ length: 12 }, () => chapterSet)];
sections[1] = { ...chapterSet, pageNumberStart: 1 };
return {
name: 'book', doc, styles,
pageFormat: 'A5',
margins: { top: 2, bottom: 2.2, left: 2.2, right: 1.6, mirrored: true },
hf: { header: null, footer: null, sections, pageCount: 120, headerDistanceCm: 1.2, footerDistanceCm: 1.2 },
language: { language: 'en', country: 'GB' },
hyphenate: true,
props: { title: 'Alice’s Adventures in Wonderland', subject: '', author: 'Lewis Carroll', keywords: '', description: '' },
shots: [
{ file: 'book', page: 3 },
{ file: 'book-spread', page: 3, columns: 2 },
],
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+120
View File
@@ -0,0 +1,120 @@
// What the showcase documents share: the node builders, a styled table, pictures as
// data-URIs and the shape run.mjs hands to buildOdt/buildDocx. Runs in the browser,
// served by the dev server (see run.mjs).
import type { PageMargins } from '../../src/lib/storage/pageMargins';
import type { PageFormat } from '../../src/lib/storage/pageFormat';
import type { HfExport } from '../../src/lib/export/odt';
import type { StyleSheet } from '../../src/lib/styles/styleSheet';
import type { NoteSettings } from '../../src/lib/storage/noteSettings';
import type { DocProperties } from '../../src/lib/storage/docProperties';
import type { PageNumbering } from '../../src/lib/storage/pageNumbering';
import type { PageDecor } from '../../src/lib/storage/pageDecor';
import type { LineNumbering } from '../../src/lib/storage/lineNumbering';
import {
DEFAULT_TABLE_LOOK, builtinTableStyles, resolveTableCell, tableLookAttr,
} from '../../src/lib/styles/tableStyles';
export { T, P, H, TBL, ROW, TC, THC, BOLD } from '../../src/lib/templates/builders';
import { P, T } from '../../src/lib/templates/builders';
export type N = Record<string, unknown>;
export const ITALIC = { type: 'italic' };
export const LI = (...content: N[]): N => ({ type: 'listItem', content });
export const UL = (...items: N[]): N => ({ type: 'bulletList', content: items });
export const OL = (...items: N[]): N => ({ type: 'orderedList', content: items });
export const BR: N = { type: 'hardBreak' };
export const FORMULA = (latex: string, display = false): N => ({ type: 'formula', attrs: { latex, display } });
export const SEQ = (category: 'figure' | 'table', number: number): N =>
({ type: 'sequenceField', attrs: { category, format: '1', number } });
export const CITE = (identifier: string, type: string, fields: Record<string, string>): N =>
({ type: 'bibliographyEntry', attrs: { identifier, type, fields, text: '' } });
export const IDX = (term: string): N => ({ type: 'indexEntry', attrs: { term } });
export const FOOTNOTE = (id: string, n: number): N =>
({ type: 'noteRef', attrs: { id, kind: 'footnote', text: String(n) } });
export const NOTE = (id: string, ...content: N[]): N =>
({ type: 'note', attrs: { id, kind: 'footnote', label: null, text: '' }, content });
export const INDEX = (index: string, title: string, extra: N = {}): N =>
({ type: 'tableOfContents', attrs: { entries: [], title, index, leader: '.', tabPosCm: null, maxLevel: 3, ...extra } });
export const COLUMNS = (count: number, gapCm: number, ...content: N[]): N =>
({ type: 'columns', attrs: { count, gapCm }, content });
export const PAGE_NUMBER: N = { type: 'pageNumber' };
export const HF = (attrs: N | null, ...content: N[]): N => ({ type: 'doc', content: [P(attrs, ...content)] });
/** A block-level picture in its own paragraph, or floating with the text around it. */
export const IMAGE = (src: string, width: number, height: number, attrs: N = {}): N =>
({ type: 'image', attrs: { src, width, height, ...attrs } });
// A picture served by the dev server (this folder's img/), as the data-URI the editor stores.
export async function picture(file: string): Promise<string> {
const blob = await (await fetch(`/scripts/showcase/img/${file}`)).blob();
return new Promise((resolve) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.readAsDataURL(blob);
});
}
export const svgDataUrl = (svg: string): string =>
`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`;
// A table in one of the built-in table styles, its look resolved per cell the way the
// editor stores it. Cells are text, or ready-made node lists; `numeric` right-aligns.
export function styledTable(
name: string, rows: (string | N[])[][],
opts: { header?: boolean; widths?: number[]; formulas?: Record<string, string>; cellFormat?: string; numeric?: number[]; attrs?: N } = {},
): N {
const style = builtinTableStyles()[name];
if (!style) throw new Error(`no table style ${name}`);
const look = { ...DEFAULT_TABLE_LOOK, ...(style.look ?? {}) };
const nRows = rows.length, cols = rows[0].length;
const header = opts.header ?? true;
const content = rows.map((row, ri) => ({
type: 'tableRow',
content: row.map((cell, ci) => {
const paint = resolveTableCell(style, { row: ri, col: ci, rows: nRows, cols }, look);
const attrs: N = { colspan: 1, rowspan: 1, colwidth: opts.widths ? [opts.widths[ci]] : null };
if (paint.fill) attrs.backgroundColor = paint.fill;
for (const [k, v] of Object.entries(paint.borders)) if (v !== null) attrs[k] = v;
if (paint.regions.length) attrs.region = paint.regions.join(' ');
const ref = `${String.fromCharCode(65 + ci)}${ri + 1}`;
if (opts.formulas?.[ref]) { attrs.formula = opts.formulas[ref]; if (opts.cellFormat) attrs.cellFormat = opts.cellFormat; }
const para = typeof cell === 'string'
? P(opts.numeric?.includes(ci) && ri > 0 ? { textAlign: 'right' } : null, ...(cell ? [T(cell)] : []))
: P(null, ...cell);
return { type: ri === 0 && header ? 'tableHeader' : 'tableCell', attrs, content: [para] };
}),
}));
return { type: 'table', attrs: { tableStyle: name, tableLook: tableLookAttr(look), repeatHeader: header, ...(opts.attrs ?? {}) }, content };
}
/** One screenshot: which page sits at the top of the window, at which zoom and theme. */
export type Shot = {
/** The page at the top of the window: by number, or the one holding this heading. */
file: string; page?: number; at?: string; zoom?: number; theme?: 'light' | 'dark';
/** Pages side by side (View ▸ page columns). */
columns?: number; markup?: boolean; spelling?: boolean;
};
export type Showcase = {
name: string;
doc: N;
margins?: PageMargins;
orientation?: 'portrait' | 'landscape';
hf?: HfExport;
language?: { language: string; country: string };
pageFormat?: PageFormat;
styles?: StyleSheet;
notes?: NoteSettings;
props?: DocProperties;
hyphenate?: boolean;
pageNumbering?: PageNumbering;
decor?: PageDecor;
lineNumbering?: LineNumbering;
shots: Shot[];
};
/** The positional argument list of buildOdt/buildDocx; `undefined` takes the default. */
export function exportArgs(s: Showcase): unknown[] {
return [s.doc, s.margins, s.orientation, s.hf, s.language, s.pageFormat, s.styles, undefined, undefined,
false, s.notes, s.props, s.hyphenate ?? false, s.pageNumbering, s.decor, s.lineNumbering];
}
+119
View File
@@ -0,0 +1,119 @@
// A society newsletter: two-column articles, pictures with captions, a sidebar box, a
// schedule table and a treasurer's table with formulas. Sans-serif, A4.
// The photographs are NASA's (public domain): AS11-40-5875 and AS08-14-2383.
import { builtinStyleSheet } from '../../src/lib/styles/styleSheet';
import {
T, P, H, BOLD, ITALIC, BR, UL, LI, SEQ, COLUMNS, IMAGE, HF, PAGE_NUMBER, picture, styledTable,
type N, type Showcase,
} from './lib';
const NAVY = '#1F3A5F';
const WHITE = { type: 'textStyle', attrs: { color: '#FFFFFF' } };
const GREY = { type: 'textStyle', attrs: { color: '#5A6B7F' } };
const SMALL = { type: 'textStyle', attrs: { fontSize: '8pt' } };
const CAPTION = (n: number, text: string): N =>
P({ styleName: 'Caption' }, T('Figure '), SEQ('figure', n), T(`: ${text}`));
// A navy band: the paragraph's own background, with borders of the same colour as padding.
const BAND = (extra: N): N => ({ backgroundColor: NAVY, borderTop: `6pt solid ${NAVY}`, borderBottom: `6pt solid ${NAVY}`,
borderLeft: `8pt solid ${NAVY}`, borderRight: `8pt solid ${NAVY}`, borderPadding: 0, textAlign: 'left', ...extra });
export async function build(): Promise<Showcase> {
const aldrin = await picture('aldrin.jpg');
const earthrise = await picture('earthrise.jpg');
const styles = builtinStyleSheet();
styles.paragraph['Standard'].text = { fontFamily: 'Liberation Sans', fontSizePt: 10 };
styles.paragraph['Standard'].para = { spaceAfter: 5, textAlign: 'justify' };
styles.paragraph['Heading 2'].text = { fontSizePt: 12, color: NAVY };
styles.paragraph['Heading 2'].para = { spaceBefore: 8, spaceAfter: 3 };
const dates: N = { type: 'textBox', attrs: {
width: 210, height: 236, fillColor: '#EEF3F8', strokeColor: NAVY, strokeWidthPt: 0.75,
wrap: 'right', wrapDist: 0.4,
}, content: [
P({ spaceAfter: 4, textAlign: 'left' }, T('Dates this autumn', BOLD, { type: 'textStyle', attrs: { color: NAVY } })),
UL(
LI(P({ textAlign: 'left' }, T('12 September', BOLD), BR, T('Members’ evening, 7:30 pm'))),
LI(P({ textAlign: 'left' }, T('26 September', BOLD), BR, T('Talk: “Back to the Moon”'))),
LI(P({ textAlign: 'left' }, T('10 October', BOLD), BR, T('Public observing night, observatory'))),
LI(P({ textAlign: 'left' }, T('7 November', BOLD), BR, T('Annual general meeting'))),
),
P({ spaceBefore: 4, textAlign: 'left' }, T('Guests are welcome at every event.', ITALIC)),
] };
const doc: N = { type: 'doc', content: [
P(BAND({ styleName: 'Title', spaceAfter: 0, borderBottom: null }), T('Eden Astronomy Society', WHITE)),
P(BAND({ spaceAfter: 10, borderTop: null }),
T('NEWSLETTER · ISSUE 3/2026 · SEPTEMBER 2026', WHITE, { type: 'textStyle', attrs: { fontSize: '9pt' } })),
P({ spaceAfter: 8 }, T('Dear members and friends,', ITALIC), BR,
T('the short nights are over, and with September our observing season begins. In this issue we look back at the solar eclipse of 12 August, invite you to a talk on the return to the Moon, and present the treasurer’s report for the first half of the year. Clear skies, the committee.', ITALIC)),
COLUMNS(2, 0.8,
H(2, T('Talk: Back to the Moon')),
P(null, T('On 26 September Dr Miriam Falk of the city planetarium speaks about the Artemis missions and about what has changed in technology and ambition since Apollo. The talk starts at 7:30 pm in the society rooms; admission is free, and a donation towards the speaker’s travel is welcome.')),
P({ textAlign: 'center', spaceAfter: 0 }, IMAGE(aldrin, 318, 329, { alt: 'Buzz Aldrin beside the flag' })),
CAPTION(1, 'Edwin “Buzz” Aldrin beside the flag, Apollo 11, July 1969. Photo: NASA.'),
P(null, T('For those who want to prepare: the society archive holds the original photographs of the landings in high resolution, along with maps of the landing sites. The 8-inch Newtonian shows the regions well on a waxing Moon, best two or three days after first quarter, when the terminator runs across the Sea of Tranquillity.')),
H(2, T('Looking back: the eclipse in Spain')),
P(null, T('Fourteen members travelled to northern Spain for the total solar eclipse on 12 August. After an overcast morning the sky cleared an hour before second contact, and the one minute and forty seconds of totality were seen in full from the high ground near Burgos. The corona showed three long streamers, and a prominence on the eastern limb was visible to the naked eye.')),
P(null, T('The group’s photographs are on the members’ pages of the website. A slide show follows at the members’ evening in December.')),
H(2, T('Picture of the month')),
P({ textAlign: 'center', spaceAfter: 0 }, IMAGE(earthrise, 260, 260, { alt: 'Earthrise over the Moon' })),
CAPTION(2, 'Earthrise, photographed from Apollo 8 on 24 December 1968. Photo: NASA.'),
P(null, T('This month not a picture from the observatory but a historic one: the Earthrise that William Anders photographed in 1968 on the fourth orbit of the Moon. It fits the theme of the talk, and it is a reminder of why we look up in the first place.')),
),
H(2, T('Observing nights this autumn')),
P(null, T('Observing nights take place at the Oak Hill observatory whenever the sky is clear. Whether an evening goes ahead is decided by the leader by 5 pm; the announcement is then on the website and in the members’ group. Dress warmly and bring a red torch.')),
styledTable('List Table Accent', [
['Date', 'Objects', 'Instrument', 'Led by'],
['19 September', 'Saturn, Neptune', '8-inch Newtonian', 'K. Brandt'],
['3 October', 'Andromeda Galaxy, the Double Cluster', 'Binoculars, refractor', 'S. Öztürk'],
['17 October', 'Moon (waxing), Jupiter', '8-inch Newtonian', 'K. Brandt'],
['31 October', 'Pleiades, Orion Nebula', 'Refractor', 'A. Lindgren'],
['14 November', 'Leonids, Uranus', 'Binoculars', 'S. Öztürk'],
], { widths: [110, 240, 170, 100] }),
P({ spaceAfter: 2 }, T('')),
P(null, T('For the Leonids on 14 November we meet at 11 pm; the meteors are most frequent after midnight. Members bringing their own telescope will find power sockets on the east side of the observatory.')),
H(2, T('Treasurer’s report, first half of 2026')),
P(null, dates, T('The society is on a sound footing. Subscriptions cover the running costs; the telescope service was a one-off expense met from the reserve. The totals in the table are computed from the individual items.')),
styledTable('Financial', [
['Item', 'Income (€)', 'Expenditure (€)'],
['Subscriptions', '2450.00', ''],
['Donations', '380.00', ''],
['Talks and events', '215.50', ''],
['Telescope service', '', '640.00'],
['Rent, society rooms', '', '900.00'],
['Insurance', '', '210.00'],
['Total', '3045.50', '1750.00'],
['Balance', '1295.50', ''],
], { widths: [200, 120, 120], numeric: [1, 2], cellFormat: 'dec2',
formulas: { B8: '=SUM(B2:B7)', C8: '=SUM(C2:C7)', B9: '=B8-C8' } }),
P({ spaceAfter: 2 }, T('')),
P(null, T('The full report will be available at the annual general meeting on 7 November. The treasurer is happy to answer questions by e-mail beforehand.')),
P({ spaceBefore: 14, borderTop: '0.5pt solid #5A6B7F', borderPadding: 4 },
T('Imprint: ', BOLD, GREY, SMALL),
T('Eden Astronomy Society, Oak Hill Observatory. Edited by the committee. Published four times a year. Contributions for the next issue to the editors by 15 November.', GREY, SMALL)),
] };
const footer = HF({ tabStops: '18r' }, T('Eden Astronomy Society · Newsletter 3/2026', GREY, SMALL),
T('\tPage ', GREY, SMALL), PAGE_NUMBER);
return {
name: 'newsletter', doc, styles,
margins: { top: 1.5, bottom: 1.5, left: 1.5, right: 1.5 },
hf: { header: null, footer, pageCount: 2, headerDistanceCm: 0.8, footerDistanceCm: 0.8 },
language: { language: 'en', country: 'GB' },
hyphenate: true,
props: { title: 'Newsletter 3/2026', subject: 'Eden Astronomy Society', author: 'The committee', keywords: 'astronomy, society', description: '' },
shots: [
{ file: 'newsletter', page: 1 },
{ file: 'newsletter-tables', page: 2 },
],
};
}
+117
View File
@@ -0,0 +1,117 @@
// Builds the showcase documents and their screenshots into docs/showcase/. The document
// modules are TypeScript the browser imports straight from the dev server, and the app's
// own exporters write the .odt/.docx; each is then opened through the file input and shot.
// `node scripts/showcase/run.mjs [regex]` limits the run to matching document names.
import { spawn } from 'node:child_process';
import { writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { ROOT, openApp } from '../../tests/browser.mjs';
const PORT = 4187;
const OUT = join(ROOT, 'docs/showcase');
const only = process.argv[2] ? new RegExp(process.argv[2]) : null;
const DOCS = ['thesis', 'thesis-review', 'book', 'newsletter'].filter((n) => !only || only.test(n));
async function devServer() {
const up = () => fetch(`http://localhost:${PORT}/`).then(() => true).catch(() => false);
if (await up()) return null;
const proc = spawn('npx', ['vite', '--port', String(PORT), '--strictPort'],
{ cwd: ROOT, stdio: 'ignore', detached: true });
for (let i = 0; i < 60 && !(await up()); i++) await new Promise((r) => setTimeout(r, 500));
return proc;
}
await mkdir(OUT, { recursive: true });
const server = await devServer();
const { browser, page, pageErrors } = await openApp(PORT);
const url = `http://localhost:${PORT}/`;
const ready = async () => {
await page.waitForSelector('.tiptap', { timeout: 30_000 });
// The page count settles a while after the last reflow; wait for it to hold still.
let last = '';
for (let i = 0; i < 40; i++) {
await page.waitForTimeout(500);
const now = await page.evaluate(() => document.querySelector('.statusbar')?.textContent ?? '');
if (now === last && now.includes('of')) break;
last = now;
}
};
const reloadWith = async (settings) => {
await page.evaluate((s) => { for (const [k, v] of Object.entries(s)) localStorage.setItem(k, v); }, settings);
await page.reload({ waitUntil: 'load' });
await ready();
};
try {
await page.goto(url, { waitUntil: 'load' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 30_000 });
for (const name of DOCS) {
const built = await page.evaluate(async (name) => {
const [{ buildOdt }, { buildDocx }, { exportArgs }, mod] = await Promise.all([
import('/src/lib/export/odt.ts'), import('/src/lib/export/docx.ts'),
import('/scripts/showcase/lib.ts'), import(`/scripts/showcase/${name}.ts`),
]);
const s = await mod.build();
const b64 = (u8) => {
let out = '';
for (let i = 0; i < u8.length; i += 0x8000) out += String.fromCharCode(...u8.subarray(i, i + 0x8000));
return btoa(out);
};
const args = exportArgs(s);
return { odt: b64(await buildOdt(...args)), docx: b64(await buildDocx(...args)), shots: s.shots };
}, name);
const odt = join(OUT, `${name}.odt`);
await writeFile(odt, Buffer.from(built.odt, 'base64'));
await writeFile(join(OUT, `${name}.docx`), Buffer.from(built.docx, 'base64'));
console.log(`${name}: .odt ${built.odt.length * 3 / 4 | 0} B, .docx ${built.docx.length * 3 / 4 | 0} B`);
// The autosave holds the document across the reloads a theme or zoom change takes;
// its pictures follow into IndexedDB a moment after the JSON.
const before = await page.evaluate(() => localStorage.getItem('edentext-doc'));
await page.setInputFiles('input.file-input', odt);
await ready();
await page.waitForFunction((b) => localStorage.getItem('edentext-doc') !== b, before, { timeout: 15_000 });
await page.waitForTimeout(2000);
for (const shot of built.shots) {
await reloadWith({
'edentext-theme': shot.theme ?? 'light',
'edentext-zoom': String(shot.zoom ?? 100),
'edentext-markup-mode': shot.markup ? 'all' : 'none',
'edentext-page-columns': String(shot.columns ?? 1),
});
// Unknown names would wear squiggles in every shot; the review shot keeps them.
if (!shot.spelling) await page.addStyleTag({ content: '.pm-spell-error { text-decoration: none !important; background: none !important; }' });
// One .paper holds every page; page n starts n-1 page cycles down it. The caret
// goes to the first paragraph in view, so the ribbon shows the body's formatting.
await page.evaluate(({ page: n, at }) => {
const editor = document.querySelector('.editor');
const paper = document.querySelector('.paper');
const cycle = parseFloat(getComputedStyle(paper).getPropertyValue('--page-height')) + 20;
const zoom = (parseFloat(localStorage.getItem('edentext-zoom')) || 100) / 100;
const top = paper.getBoundingClientRect().top - editor.getBoundingClientRect().top + editor.scrollTop;
if (at) {
const h = [...document.querySelectorAll('.tiptap :is(h1, h2, h3, p)')].find((e) => e.textContent.trim() === at);
n = Math.floor((h.getBoundingClientRect().top - paper.getBoundingClientRect().top) / (cycle * zoom)) + 1;
}
editor.scrollTop = top + (n - 1) * cycle * zoom - 12;
const limit = editor.getBoundingClientRect().top + 40;
const plain = (p) => [...p.querySelectorAll('*')].every((e) => /^(SPAN|STRONG|EM|B|I|U|S|A|SUP|SUB|BR)$/.test(e.tagName));
const first = [...document.querySelectorAll('.tiptap > p')].find((p) => p.getBoundingClientRect().top > limit && p.textContent.trim() && plain(p));
const view = document.querySelector('.tiptap').editor;
if (first) view.commands.focus(view.view.posAtDOM(first, 0) + 1, { scrollIntoView: false });
}, { page: shot.page ?? 1, at: shot.at });
await page.waitForTimeout(500);
await page.screenshot({ path: join(OUT, `${shot.file}.png`) });
console.log(` ${shot.file}.png`);
}
}
} finally {
if (pageErrors.length) console.log(`page errors: ${pageErrors.join(' | ')}`);
await browser.close();
if (server) process.kill(-server.pid);
}
+3
View File
@@ -0,0 +1,3 @@
// The thesis as it comes back from the supervisor: tracked changes, comments, DRAFT.
import { build as thesis } from './thesis';
export const build = () => thesis(true);
+197
View File
@@ -0,0 +1,197 @@
// A master's thesis: numbered chapters, contents, footnotes, formulas, a figure and
// tables with captions, cross-references, citations with a bibliography, an index and a
// running header. `review` adds tracked changes, comments and a DRAFT watermark.
import { builtinStyleSheet } from '../../src/lib/styles/styleSheet';
import {
T, P, H, BOLD, ITALIC, OL, LI, FORMULA, SEQ, CITE, IDX, FOOTNOTE, NOTE, INDEX, IMAGE, HF,
PAGE_NUMBER, svgDataUrl, styledTable, type N, type Showcase,
} from './lib';
const CHART = svgDataUrl(`<svg xmlns="http://www.w3.org/2000/svg" width="560" height="260" viewBox="0 0 560 260" font-family="Liberation Sans, Arial, sans-serif" font-size="12">
<rect width="560" height="260" fill="#fff"/>
<g stroke="#bbb" stroke-width="1"><line x1="200" y1="20" x2="200" y2="215"/><line x1="200" y1="215" x2="540" y2="215"/></g>
<g stroke="#e4e4e4" stroke-dasharray="3 3"><line x1="268" y1="20" x2="268" y2="215"/><line x1="336" y1="20" x2="336" y2="215"/><line x1="404" y1="20" x2="404" y2="215"/><line x1="472" y1="20" x2="472" y2="215"/><line x1="540" y1="20" x2="540" y2="215"/></g>
<g fill="#666" text-anchor="middle"><text x="200" y="232">0</text><text x="268" y="232">20</text><text x="336" y="232">40</text><text x="404" y="232">60</text><text x="472" y="232">80</text><text x="540" y="232">100</text><text x="370" y="252">Page breaks on the reference line (%)</text></g>
<g fill="#9fb6d1"><rect x="200" y="32" width="233" height="26"/><rect x="200" y="78" width="269" height="26"/></g>
<g fill="#3f6a9c"><rect x="200" y="124" width="311" height="26"/><rect x="200" y="170" width="327" height="26"/></g>
<g fill="#222" text-anchor="end"><text x="190" y="49">Greedy</text><text x="190" y="95">Greedy + widow/orphan</text><text x="190" y="141">Cost model</text><text x="190" y="187">Cost model + keeps</text></g>
<g fill="#222"><text x="438" y="49">68.4</text><text x="474" y="95">79.1</text><text x="516" y="141">91.6</text><text x="532" y="187">96.2</text></g>
</svg>`);
const AUTHOR = 'Nora Weiland';
const REVIEWER = 'Prof. Halvorsen';
const BOOKMARK = (name: string) => ({ type: 'bookmark', attrs: { name } });
const REF = (name: string, format: 'text' | 'page', text: string): N =>
({ type: 'crossRef', attrs: { name, format, text } });
const FRONT = (text: string): N => P({ styleName: 'Front Matter Heading', breakBefore: 'page' }, T(text));
const CAP = (category: 'figure' | 'table', n: number, text: string, bookmark?: string): N =>
P({ styleName: 'Caption', textAlign: 'center' },
T(category === 'figure' ? 'Figure ' : 'Table ', ...(bookmark ? [BOOKMARK(bookmark)] : [])), SEQ(category, n), T(`: ${text}`));
const KNUTH = () => CITE('Knuth1981', 'article', { author: 'Knuth, D. E., & Plass, M. F.', title: 'Breaking paragraphs into lines', year: '1981', publisher: 'Software: Practice and Experience, 11(11), 1119–1184' });
const PLASS = () => CITE('Plass1981', 'phdthesis', { author: 'Plass, M. F.', title: 'Optimal pagination techniques for automatic typesetting systems', year: '1981', publisher: 'Stanford University' });
const BRINGHURST = () => CITE('Bringhurst2004', 'book', { author: 'Bringhurst, R.', title: 'The elements of typographic style (3rd ed.)', year: '2004', publisher: 'Hartley & Marks' });
const BKW = () => CITE('Brueggemann2003', 'inproceedings', { author: 'Brüggemann-Klein, A., Klein, R., & Wohlfeil, S.', title: 'On the pagination of complex documents', year: '2003', publisher: 'Computer Science in Perspective, LNCS 2598, 49–68. Springer' });
const MITTELBACH = () => CITE('Mittelbach2016', 'inproceedings', { author: 'Mittelbach, F.', title: 'A general framework for globally optimized pagination', year: '2016', publisher: 'Proceedings of the ACM Symposium on Document Engineering, 11–20. ACM' });
const ODF = () => CITE('OASIS2021', 'techreport', { author: 'OASIS', title: 'Open Document Format for Office Applications (OpenDocument) Version 1.3', year: '2021', publisher: 'OASIS Standard' });
const CSS = () => CITE('W3C2018', 'techreport', { author: 'W3C', title: 'CSS Fragmentation Module Level 3', year: '2018', publisher: 'W3C Candidate Recommendation' });
export function build(review = false): Showcase {
const styles = builtinStyleSheet();
styles.paragraph['Standard'].para = { textAlign: 'justify', lineHeight: '1.15', spaceAfter: 6 };
styles.paragraph['Front Matter Heading'] = { name: 'Front Matter Heading', parent: 'Heading 1', next: 'Standard',
para: { spaceAfter: 12 }, text: {} };
styles.paragraph['Heading 1'].para = { spaceBefore: 0, spaceAfter: 18 };
styles.paragraph['Heading 1'].text = { fontSizePt: 22 };
styles.outline = [
{ format: '1', prefix: '', suffix: ' ', displayLevels: 1, start: 1 },
{ format: '1', prefix: '', suffix: ' ', displayLevels: 2, start: 1 },
{ format: '1', prefix: '', suffix: ' ', displayLevels: 3, start: 1 },
];
const stamp = (n: number) => `2026-08-${String(10 + n).padStart(2, '0')}T14:${String(n).padStart(2, '0')}:00`;
// The reviewer's marks: only the review variant carries them, the clean one reads on.
const ins = (text: string, n: number): N => review
? T(text, { type: 'insertion', attrs: { id: `ins${n}`, author: REVIEWER, date: stamp(n) } }) : T(text);
const del = (text: string, n: number): N[] => review
? [T(text, { type: 'deletion', attrs: { id: `del${n}`, author: REVIEWER, date: stamp(n) } })] : [];
const commented = (text: string, n: number, note: string, reply?: string): N => review
? T(text, { type: 'comment', attrs: { id: `c${n}`, author: REVIEWER, date: stamp(n), text: note, resolved: false,
replies: reply ? [{ author: AUTHOR, date: stamp(n + 10), text: reply }] : [] } })
: T(text);
const chapter = (title: string, bookmark?: string): N =>
({ type: 'heading', attrs: { level: 1, breakBefore: 'page' }, content: [T(title, ...(bookmark ? [BOOKMARK(bookmark)] : []))] });
const doc: N = { type: 'doc', content: [
P({ styleName: 'Title', spaceBefore: 150 }, T('Widow and Orphan Control in Browser-Based Word Processing')),
P({ styleName: 'Subtitle', spaceBefore: 12, spaceAfter: 90 }, T('A Cost Model for Page Breaks in DOM-Rendered Documents')),
P({ textAlign: 'center', spaceAfter: 30 }, T("Master's Thesis", ITALIC)),
P({ textAlign: 'center', spaceAfter: 0 }, T('submitted by')),
P({ textAlign: 'center', spaceAfter: 30 }, T(AUTHOR, BOLD)),
P({ textAlign: 'center', spaceAfter: 0 }, T('Department of Computer Science')),
P({ textAlign: 'center', spaceAfter: 0 }, T('Eden Institute of Technology')),
P({ textAlign: 'center', spaceBefore: 30, spaceAfter: 0 }, T('Supervisor: Prof. Dr. Ingrid Halvorsen')),
P({ textAlign: 'center', spaceAfter: 0 }, T('Second examiner: Dr. Tomasz Wiśniewski')),
P({ textAlign: 'center', spaceBefore: 30 }, T('September 2026')),
FRONT('Abstract'),
P(null, T('Word processors that run in the browser lay their pages out with the CSS engine of the host, which knows lines and boxes but not pages. Page breaks are therefore computed by the application, and the typographic rules that desktop word processors have applied for decades, keeping the first and last lines of a paragraph together with their neighbours and keeping a heading with the text it introduces, have to be reimplemented on top of measured geometry. This thesis formulates page breaking as a cost minimisation over the measured line boxes of a document and evaluates it against the pagination of a desktop reference implementation. On a corpus of 71 documents the cost model places 96.2 percent of all page breaks on the same line as the reference, against 68.4 percent for the greedy strategy most browser editors use, and removes every widow and orphan the greedy strategy produces. The model runs incrementally, so a document of several hundred pages re-paginates within the frame budget of an interactive editor.')),
P(null, T('Keywords: ', BOLD), T('pagination, widows and orphans, cost model, dynamic programming, word processing, CSS fragmentation')),
FRONT('Contents'),
INDEX('toc', '', { maxLevel: 2 }),
chapter('Introduction'),
H(2, T('Motivation')),
P(null, T('A page break that leaves a single line of a paragraph at the bottom of a page, an '), T('orphan'), IDX('orphan'), T(', or carries a single line over to the top of the next page, a '), T('widow'), IDX('widow'), T(', has been considered a fault of typesetting since the days of hand composition '), BRINGHURST(), T('. Desktop word processors avoid both by default, and their users have come to expect it without knowing the rule. Word processors that run in the browser have mostly not: the layout engine they build on, CSS, has no concept of a page in its continuous-media mode, and the paged mode that does exist is meant for print, not for an editor that re-lays out its text on every keystroke.'), FOOTNOTE('f1', 1)),
P(null, commented('This thesis asks how the pagination quality of desktop word processors can be reached in the browser, and at what cost in running time.', 1, 'State the two research questions here in one sentence, then expand in 1.2.', 'Done, see the revised sentence below.'), T(' The line-breaking problem was solved by Knuth and Plass with a dynamic programme over the possible break points of a paragraph '), KNUTH(), T(', and Plass extended the same idea to pages '), PLASS(), T('. The pagination of complex documents with floating figures and footnotes has since been studied both as an optimisation problem '), BKW(), T(' and as a general framework for globally optimised pagination '), MITTELBACH(), T('. What none of this work considers is the situation of a browser-based editor: the line boxes are not computed by the application but measured from a layout it does not control, and they change while the user types.')),
P(null, ...del('This work presents a way to do this. ', 2), ins('This thesis adapts the cost-based formulation to that situation. ', 3), T('The document is measured after every change, and the measured lines are the input to a cost model whose minimum is found incrementally, so that only the pages after the edit are recomputed.')),
H(2, T('Research Questions')),
P(null, T('The work is organised around three questions.')),
OL(
LI(P(null, T('RQ1 ', BOLD), T('How closely can a cost-based pagination over measured line boxes reproduce the page breaks of a desktop reference implementation?'))),
LI(P(null, T('RQ2 ', BOLD), T('Which share of widows, orphans and separated headings does each strategy leave in a realistic corpus?'))),
LI(P(null, T('RQ3 ', BOLD), T('Can the model be evaluated incrementally within the frame budget of an interactive editor, for documents of several hundred pages?'))),
),
H(2, T('Structure of this Thesis')),
P(null, T('Chapter 2 introduces the typographic rules and the way the two desktop reference implementations and the CSS fragmentation model apply them. Chapter 3 defines the cost model and describes the corpus and the measurement procedure. Chapter 4 reports the results for the three research questions, Chapter 5 discusses them and their threats to validity, and Chapter 6 concludes.')),
chapter('Background'),
H(2, T('Widows, Orphans and Keeps')),
P(null, T('The vocabulary is older than the software. An '), T('orphan', ITALIC), T(' is the first line of a paragraph left alone at the foot of a page; a '), T('widow', ITALIC), T(' is the last line of a paragraph carried alone to the head of the next '), BRINGHURST(), T('. Both are avoided by moving one more line across the break, which shortens the page by a line. Word processors expose this as '), T('widow control', ITALIC), IDX('widow control'), T(' and count a minimum of two lines on either side; the setting can be raised per paragraph or switched off.')),
P(null, T('Two further rules concern the relation between paragraphs rather than the lines within one. '), T('Keep with next', ITALIC), IDX('keep with next'), T(' binds a paragraph to the one that follows, so that a heading never ends a page; '), T('keep lines together', ITALIC), IDX('keep lines together'), T(' forbids any break inside a paragraph. A caption is kept with its figure the same way. These keeps can conflict with each other and with the page height: a long chain of kept paragraphs cannot be honoured on one page, and every implementation has to decide which keep to give up first.')),
H(2, T('Pagination in Desktop Word Processors')),
P(null, T('Both desktop reference implementations paginate greedily in the flow direction, filling a page with lines until the next line does not fit, and then apply the keeps backwards from the break: if the break would separate a heading from its text, or leave an orphan, the break moves up. The rules are applied in a fixed order, and the result is documented well enough that a file format can carry it. The OpenDocument format stores widow and orphan minimums and both keeps as paragraph properties '), ODF(), T(', and a document saved by one implementation is expected to break on the same lines in the other.'), FOOTNOTE('f2', 2)),
P(null, T('The consequence for this work is that the reference is not a single algorithm but an observed behaviour, and that the observed behaviour can be captured as a set of page break positions per document. This is what the measurement in Section 3.3 compares against.')),
H(2, T('Layout in the Browser')),
P(null, T('CSS fragmentation '), CSS(), T(' defines how a box is split across fragmentainers, which include pages in print. The specification carries the same vocabulary as the word processors: '), T('orphans', ITALIC), T(' and '), T('widows', ITALIC), T(' are properties, '), T('break-after: avoid', { type: 'charStyle', attrs: { name: 'Source Text' } }), T(' expresses keep with next, and '), T('break-inside: avoid', { type: 'charStyle', attrs: { name: 'Source Text' } }), T(' keeps lines together. In continuous media, however, none of this applies: a document rendered into a scrolling page is one fragmentainer of unbounded height, and the browser never breaks it.')),
P(null, T('A browser-based editor that shows pages therefore has two options. It can render every page into its own box and move content between the boxes, which turns every edit into a re-render of the affected pages; or it can render the document as one flow and draw the page boundaries over it, moving blocks down with spacers where a break has to fall. The second option keeps the editing surface intact and is the one this work assumes. Its pagination is then a placement of spacers, and the question is where.')),
chapter('Method'),
H(2, T('A Cost Model for Page Breaks', 'cost-model')),
P(null, T('Let the document be a sequence of line boxes '), FORMULA('l_1, \\ldots, l_n'), T(' with measured heights '), FORMULA('h_i'), T(', grouped into paragraphs, and let '), FORMULA('H'), T(' be the height of the text area of a page. A pagination is a sequence of break positions '), FORMULA('b_1 < b_2 < \\cdots < b_m'), T(', and the '), FORMULA('k'), T('-th page holds the lines from '), FORMULA('b_{k-1}'), T(' up to '), FORMULA('b_k'), T('. Its cost sums the faults it produces and the space it wastes:')),
P({ textAlign: 'center' }, FORMULA('C(b) = \\sum_{k=1}^{m} \\left( \\alpha\\, w_k + \\beta\\, o_k + \\gamma\\, s_k \\right) + \\lambda \\sum_{k=1}^{m} \\left( 1 - \\frac{f_k}{H} \\right)^{2}', true)),
P(null, T('where '), FORMULA('w_k, o_k, s_k \\in \\{0, 1\\}'), T(' record whether page '), FORMULA('k'), T(' ends with a widow, an orphan or a separated heading, '), FORMULA('f_k'), T(' is the height of the lines it holds, and '), FORMULA('\\alpha, \\beta, \\gamma, \\lambda'), T(' weight the terms. A keep the user has set explicitly is not weighted but forbidden: a break inside a paragraph marked to keep its lines together, or after one marked to keep with the next, is excluded from the candidate set.')),
P(null, T('The minimum is found by dynamic programming over the break positions, in the manner of Knuth and Plass '), KNUTH(), T('. With '), FORMULA('c(i, j)'), T(' the cost of a page holding the lines '), FORMULA('i+1'), T(' to '), FORMULA('j'), T(', the optimal cost of paginating the first '), FORMULA('j'), T(' lines is')),
P({ textAlign: 'center' }, FORMULA('C^{*}(j) = \\min_{i < j} \\left( C^{*}(i) + c(i, j) \\right), \\quad C^{*}(0) = 0', true)),
P(null, T('and only the '), FORMULA('i'), T(' whose lines fit on one page need to be considered, so each step examines at most a page of lines. An edit invalidates the measured heights of the lines it touches and every '), FORMULA('C^{*}(j)'), T(' after them; the recurrence is re-evaluated from the first invalidated line, and stops as soon as it reproduces a break the previous solution already had, because from there on the two solutions coincide.')),
H(2, T('The Corpus')),
P(null, T('The corpus consists of 71 documents in four categories, collected from public archives and from the author’s own institution, and covering the kinds of text a word processor is used for. '), T('Table 1 on page '), REF('tab-corpus', 'page', '7'), T(' lists them. Every document was saved from each reference implementation and its page breaks were recorded, so that each strategy can be compared against both.')),
styledTable('Academic', [
['Category', 'Documents', 'Pages', 'Mean pages'],
['Theses and dissertations', '12', '1480', '123.3'],
['Reports and papers', '20', '640', '32.0'],
['Letters and forms', '35', '52', '1.5'],
['Books', '4', '1210', '302.5'],
['Total', '71', '3382', '47.6'],
], { widths: [260, 120, 120, 120], numeric: [1, 2, 3], cellFormat: 'dec2',
formulas: { B6: '=SUM(B2:B5)', C6: '=SUM(C2:C5)', D6: '=C6/B6' } }),
CAP('table', 1, 'The corpus by category. The totals are computed in the table.', 'tab-corpus'),
H(2, T('Measurement')),
P(null, T('For every document and every strategy, the set of lines on which a page ends is compared with the set recorded from the reference. A break counts as '), T('on the reference line', ITALIC), T(' if both sets contain it. The share of such breaks is the '), T('agreement', ITALIC), IDX('agreement'), T(' of the strategy; widows, orphans and separated headings are counted from the strategy’s own breaks, independently of the reference. Running time is the wall-clock time of one re-pagination after a single character is inserted on the first page, measured in the browser with a warm cache and reported as the median of 20 runs.')),
P(null, T('Four strategies are compared: the greedy fill that most browser editors implement, greedy fill with widow and orphan control applied backwards from the break, the cost model of Section 3.1 with the faults weighted but no explicit keeps, and the cost model with the keeps of the document honoured as hard constraints.')),
chapter('Results'),
H(2, T('Break Agreement')),
P(null, T('Figure 1 on page '), REF('fig-agreement', 'page', '8'), T(' shows the agreement of the four strategies with the reference over the whole corpus. The greedy strategy agrees on roughly two breaks in three; the disagreements are almost all one line early or late, caused by a widow or orphan the reference avoided. Adding widow and orphan control closes about a third of the gap. The cost model without keeps reaches 91.6 percent, and honouring the keeps raises it to 96.2 percent. The remaining differences are concentrated in the books, where the reference implementations disagree with each other as often as the model disagrees with either.')),
P({ textAlign: 'center', spaceAfter: 0 }, IMAGE(CHART, 480, 223, { alt: 'Agreement per strategy' })),
CAP('figure', 1, 'Agreement of each strategy with the reference pagination, over 3,382 pages.', 'fig-agreement'),
H(2, T('Widow and Orphan Rates')),
P(null, T('Table 2 counts the faults each strategy leaves. The greedy strategy produces a widow or orphan on about one page in nine, which matches the rate of paragraphs whose last line happens to fall near a page boundary. Both variants of the cost model remove them entirely; the model with keeps also removes every separated heading, while the model without keeps still leaves 14, all of them headings followed by a figure whose caption did not fit.')),
styledTable('Academic', [
['Strategy', 'Widows', 'Orphans', 'Separated headings', 'Agreement (%)'],
['Greedy', '214', '167', '96', '68.4'],
['Greedy + widow/orphan', '0', '0', '96', '79.1'],
['Cost model', '0', '0', '14', '91.6'],
['Cost model + keeps', '0', '0', '0', '96.2'],
], { widths: [200, 90, 90, 150, 110], numeric: [1, 2, 3, 4] }),
CAP('table', 2, 'Faults left by each strategy in the corpus, and its agreement with the reference.'),
H(2, T('Running Time')),
P(null, T('The incremental evaluation re-paginates the longest document of the corpus, a book of 412 pages, in a median of 6.8 milliseconds after an edit on its first page, of which 5.1 milliseconds are spent measuring line boxes. A full re-pagination of the same document takes 182 milliseconds. The measured cost grows with the number of pages after the edit until the recurrence reproduces a previous break, which in the corpus happens within three pages in 94 percent of the edits.')),
chapter('Discussion'),
P(null, T('The results answer the three questions in the affirmative, with two qualifications. The first is that the reference is itself not unique: the two desktop implementations disagree on 3.1 percent of the breaks in the corpus, mostly in the books, so an agreement above 97 percent with one of them is not attainable without lowering the agreement with the other. The second is that the cost model reproduces the rules of the reference, not its exceptions; a document that relies on a particular implementation giving up a keep in a particular order will paginate differently.')),
H(2, T('Threats to Validity')),
P(null, T('The corpus is biased towards academic writing, where headings and captions are frequent and the keeps matter most; a corpus of letters alone would show smaller differences between the strategies. The running times were measured in one browser engine on one machine, and the measurement of line boxes, which dominates them, is the part most dependent on the engine. The reference page breaks were recorded once per implementation and version; a later version may break differently.')),
H(2, T('Limitations')),
P(null, T('The model treats footnotes as part of the line that references them and reserves their height on the same page, which is what both references do for short notes but not for notes that themselves span pages. Floating figures are placed before pagination and not moved by it. Neither limitation affected the corpus, which contains no footnote longer than a page and no float that the references moved.')),
chapter('Conclusion and Future Work'),
P(null, T('Page breaking in a browser-based editor can be formulated as a cost minimisation over measured line boxes and solved incrementally, and doing so brings its pagination within a few percent of the desktop reference implementations while removing every widow and orphan. The remaining disagreement is largely disagreement between the references themselves. Future work should extend the model to footnotes that span pages and to floats that pagination may move, and should validate the running times across engines. The model as defined in '), REF('cost-model', 'text', 'A Cost Model for Page Breaks'), T(', page '), REF('cost-model', 'page', '6'), T(', is implemented in the editor that produced this document.')),
INDEX('bibliography', 'Bibliography', { citationStyle: 'apa' }),
INDEX('alphabetical', 'Index'),
{ type: 'noteSection', content: [
NOTE('f1', T('CSS paged media exists for printing; the browsers that implement it re-lay out the whole document for every page, which is what an interactive editor cannot afford.')),
NOTE('f2', T('Both implementations honour widow and orphan minimums of two lines by default; the corpus documents keep that default.')),
] },
] };
// Chapter and page number on every page but the title page.
const header = HF({ tabStops: '16r' },
{ type: 'chapterField', attrs: { level: 1, text: 'Introduction' } },
T('\t'), PAGE_NUMBER);
return {
name: review ? 'thesis-review' : 'thesis', doc, styles,
margins: { top: 2.5, bottom: 2.5, left: 2.5, right: 2.5 },
hf: { header, footer: null, headerFirst: null, footerFirst: null, differentFirstPage: true,
pageCount: 12, headerDistanceCm: 1.5, footerDistanceCm: 1.5 },
language: { language: 'en', country: 'US' },
hyphenate: true,
props: { title: 'Widow and Orphan Control in Browser-Based Word Processing', subject: "Master's Thesis",
author: AUTHOR, keywords: 'pagination, widows, orphans, cost model', description: '' },
decor: review ? { background: null, border: null,
watermark: { text: 'DRAFT', font: 'Liberation Sans', color: '#C8C8C8', angle: 45, transparency: 60 } } : undefined,
shots: review
? [{ file: 'thesis-review', at: 'Introduction', markup: true }]
: [
{ file: 'thesis', at: 'Method' },
{ file: 'thesis-dark', at: 'Method', theme: 'dark' },
{ file: 'thesis-contents', at: 'Contents' },
{ file: 'thesis-overview', page: 1, zoom: 50, columns: 2 },
],
};
}