orz-paged 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +146 -0
  2. package/assets/app.js +413 -0
  3. package/assets/templates/article-page.md +71 -0
  4. package/assets/templates/article-section.md +83 -0
  5. package/assets/templates/cover-letter.md +53 -0
  6. package/assets/templates/cv-elegant.md +93 -0
  7. package/assets/templates/cv-modern.md +86 -0
  8. package/assets/templates/cv.md +45 -0
  9. package/assets/templates/exam-page.md +76 -0
  10. package/assets/templates/exam-section.md +59 -0
  11. package/assets/templates/letter.md +47 -0
  12. package/assets/templates/note.md +28 -0
  13. package/assets/templates/report-page.md +72 -0
  14. package/assets/templates/report-section.md +80 -0
  15. package/assets/themes/base.css +538 -0
  16. package/assets/themes/beige-decent-1.css +103 -0
  17. package/assets/themes/beige-decent-2.css +92 -0
  18. package/assets/themes/light-academic-1.css +114 -0
  19. package/assets/themes/light-academic-2.css +99 -0
  20. package/assets/themes/light-neat-1.css +90 -0
  21. package/assets/themes/light-neat-2.css +101 -0
  22. package/assets/themes/light-neat-3.css +91 -0
  23. package/dist/browser-entry.js +302 -0
  24. package/dist/cli.js +167 -0
  25. package/dist/doc/dynamic.js +57 -0
  26. package/dist/doc/elements.js +813 -0
  27. package/dist/doc/fonts.js +83 -0
  28. package/dist/doc/nyml.js +140 -0
  29. package/dist/doc/page-css.js +230 -0
  30. package/dist/doc/settings.js +390 -0
  31. package/dist/doc/templates.js +147 -0
  32. package/dist/doc/theme-fonts.js +10 -0
  33. package/dist/orz-paged.browser.js +1524 -0
  34. package/dist/paged.js +31 -0
  35. package/dist/render-paged.js +123 -0
  36. package/dist/template.js +242 -0
  37. package/dist/types.js +11 -0
  38. package/orz-paged-skills/SKILL.md +547 -0
  39. package/package.json +51 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Font presets — M3.
3
+ *
4
+ * Maps each curated {@link FontPresetName} to a {@link FontPreset}: a CDN
5
+ * stylesheet URL (`@fontsource/*` served via jsDelivr) plus the matching CSS
6
+ * `font-family` stack. orz-paged assumes internet access, so fonts load from
7
+ * the CDN rather than being bundled.
8
+ *
9
+ * `system-serif` is the one preset with **no** webfont — it relies on the
10
+ * platform's Times-style serif and ships an empty `cssUrl`.
11
+ *
12
+ * Source of truth for the curated list: `docs/document-model.md` (Tier A) and
13
+ * `src/types.ts` (`FontPresetName`).
14
+ */
15
+ /** jsDelivr `@fontsource` stylesheet for a given package id (major v5). */
16
+ function fontsourceUrl(id) {
17
+ return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/index.css`;
18
+ }
19
+ /** Times-style fallback stack for the webfont-less `system-serif` preset. */
20
+ const SYSTEM_SERIF_FAMILY = '"Times New Roman", Times, Georgia, "Liberation Serif", serif';
21
+ /**
22
+ * Every curated preset → its CDN stylesheet + CSS family stack. `system-serif`
23
+ * carries an empty `cssUrl` (no network font).
24
+ */
25
+ export const FONT_PRESETS = {
26
+ 'system-serif': {
27
+ cssUrl: '',
28
+ family: SYSTEM_SERIF_FAMILY,
29
+ },
30
+ 'source-serif-4': {
31
+ cssUrl: fontsourceUrl('source-serif-4'),
32
+ family: '"Source Serif 4", Georgia, "Times New Roman", serif',
33
+ },
34
+ lora: {
35
+ cssUrl: fontsourceUrl('lora'),
36
+ family: '"Lora", Georgia, "Times New Roman", serif',
37
+ },
38
+ 'crimson-pro': {
39
+ cssUrl: fontsourceUrl('crimson-pro'),
40
+ family: '"Crimson Pro", Georgia, "Times New Roman", serif',
41
+ },
42
+ 'noto-serif': {
43
+ cssUrl: fontsourceUrl('noto-serif'),
44
+ family: '"Noto Serif", Georgia, "Times New Roman", serif',
45
+ },
46
+ inter: {
47
+ cssUrl: fontsourceUrl('inter'),
48
+ family: '"Inter", "Helvetica Neue", Helvetica, Arial, sans-serif',
49
+ },
50
+ 'ibm-plex-sans': {
51
+ cssUrl: fontsourceUrl('ibm-plex-sans'),
52
+ family: '"IBM Plex Sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
53
+ },
54
+ roboto: {
55
+ cssUrl: fontsourceUrl('roboto'),
56
+ family: '"Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif',
57
+ },
58
+ raleway: {
59
+ cssUrl: fontsourceUrl('raleway'),
60
+ family: '"Raleway", "Helvetica Neue", Helvetica, Arial, sans-serif',
61
+ },
62
+ 'noto-sans': {
63
+ cssUrl: fontsourceUrl('noto-sans'),
64
+ family: '"Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
65
+ },
66
+ 'courier-prime': {
67
+ cssUrl: fontsourceUrl('courier-prime'),
68
+ family: '"Courier Prime", "Courier New", Courier, monospace',
69
+ },
70
+ };
71
+ /** Sensible fallback for an unknown preset name: the webfont-less system serif. */
72
+ const SYSTEM_FALLBACK = {
73
+ cssUrl: '',
74
+ family: SYSTEM_SERIF_FAMILY,
75
+ };
76
+ /**
77
+ * Resolve a preset by name. Unknown names fall back to a webfont-less system
78
+ * serif (empty `cssUrl`) rather than throwing.
79
+ */
80
+ export function fontPreset(name) {
81
+ const preset = FONT_PRESETS[name];
82
+ return preset ?? SYSTEM_FALLBACK;
83
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Generic nyml-block scanner — shared by the settings reader (M1) and the
3
+ * assembler (A1).
4
+ *
5
+ * `settings.ts` parses the single `kind: document` block inline; this module is
6
+ * the reusable version: it finds **every** `{{nyml … }}` block in a source,
7
+ * parses its YAML-ish `key: value` body (including `key: |` multiline blocks),
8
+ * reads the `kind:` discriminator, and reports the block's char offsets so a
9
+ * caller can splice it out / replace it.
10
+ *
11
+ * Values are always strings: surrounding quotes are stripped, multiline `|`
12
+ * blocks are dedented and joined with `\n`. The `{{nyml … }}` grammar matches
13
+ * what `parseDocSettings` already accepts (`}` is allowed inside the body; only
14
+ * the first `}}` terminates the block).
15
+ */
16
+ const OPEN = '{{nyml';
17
+ /** Find the index of the `}}` that closes a `{{nyml` opened at/after `from`. */
18
+ function findClosing(source, from) {
19
+ for (let i = from; i < source.length - 1; i++) {
20
+ if (source[i] === '}' && source[i + 1] === '}')
21
+ return i;
22
+ }
23
+ return -1;
24
+ }
25
+ /** Strip a single pair of matching surrounding quotes, if present. */
26
+ function stripQuotes(s) {
27
+ if (s.length >= 2) {
28
+ const first = s[0];
29
+ const last = s[s.length - 1];
30
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
31
+ return s.slice(1, -1);
32
+ }
33
+ }
34
+ return s;
35
+ }
36
+ /**
37
+ * Parse a nyml body into a flat `key: value` map plus its `kind`. Supports
38
+ * `key: |` multiline blocks: subsequent lines indented more than the key are
39
+ * dedented (block style) and joined with `\n`. The `kind:` line populates
40
+ * `kind` rather than `fields`.
41
+ */
42
+ function parseBody(inner) {
43
+ const fields = {};
44
+ let kind = '';
45
+ const lines = inner.split('\n');
46
+ let i = 0;
47
+ while (i < lines.length) {
48
+ const line = lines[i];
49
+ const m = /^(\s*)([A-Za-z0-9_]+)\s*:\s?(.*)$/.exec(line);
50
+ if (!m) {
51
+ i++;
52
+ continue;
53
+ }
54
+ const indent = m[1].length;
55
+ const key = m[2];
56
+ const value = m[3];
57
+ if (value.trim() === '|') {
58
+ // Multiline block: gather following lines indented more than this key.
59
+ const collected = [];
60
+ let blockIndent = -1;
61
+ i++;
62
+ while (i < lines.length) {
63
+ const next = lines[i];
64
+ if (next.trim() === '') {
65
+ collected.push('');
66
+ i++;
67
+ continue;
68
+ }
69
+ const nextIndent = next.match(/^[ \t]*/)?.[0].length ?? 0;
70
+ if (nextIndent <= indent)
71
+ break;
72
+ if (blockIndent === -1)
73
+ blockIndent = nextIndent;
74
+ collected.push(next.slice(Math.min(blockIndent, nextIndent)));
75
+ i++;
76
+ }
77
+ while (collected.length && collected[collected.length - 1] === '') {
78
+ collected.pop();
79
+ }
80
+ const joined = collected.join('\n');
81
+ if (key === 'kind')
82
+ kind = joined.trim();
83
+ else
84
+ fields[key] = joined;
85
+ continue;
86
+ }
87
+ const v = stripQuotes(value.trim());
88
+ if (key === 'kind')
89
+ kind = v;
90
+ else
91
+ fields[key] = v;
92
+ i++;
93
+ }
94
+ return { kind, fields };
95
+ }
96
+ /** Char ranges `[start, end)` covered by HTML comments `<!-- … -->`. */
97
+ function commentRanges(source) {
98
+ const ranges = [];
99
+ let i = 0;
100
+ while (true) {
101
+ const open = source.indexOf('<!--', i);
102
+ if (open === -1)
103
+ break;
104
+ const close = source.indexOf('-->', open + 4);
105
+ if (close === -1) {
106
+ ranges.push([open, source.length]);
107
+ break;
108
+ } // unclosed → to end
109
+ ranges.push([open, close + 3]);
110
+ i = close + 3;
111
+ }
112
+ return ranges;
113
+ }
114
+ /**
115
+ * Scan a source for every `{{nyml … }}` block, in document order. Each result
116
+ * carries its parsed `kind`, `fields`, and `[start, end)` char offsets. Blocks
117
+ * inside an HTML comment (`<!-- … -->`) are skipped — so a commented-out element
118
+ * (e.g. content the editor's template picker preserves) does not render.
119
+ */
120
+ export function scanNymlBlocks(source) {
121
+ const out = [];
122
+ const comments = commentRanges(source);
123
+ const inComment = (pos) => comments.some(([a, b]) => pos >= a && pos < b);
124
+ let searchFrom = 0;
125
+ while (searchFrom < source.length) {
126
+ const open = source.indexOf(OPEN, searchFrom);
127
+ if (open === -1)
128
+ break;
129
+ const close = findClosing(source, open + OPEN.length);
130
+ if (close === -1)
131
+ break;
132
+ if (!inComment(open)) {
133
+ const inner = source.slice(open + OPEN.length, close);
134
+ const { kind, fields } = parseBody(inner);
135
+ out.push({ kind, fields, start: open, end: close + 2 });
136
+ }
137
+ searchFrom = close + 2;
138
+ }
139
+ return out;
140
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Page CSS — M5.
3
+ *
4
+ * Turns a fully-resolved {@link DocSettings} into the `@page` rules, margin-box
5
+ * running headers/footers, page-number counters, `:root` design tokens, and
6
+ * layout-behavior CSS that paged.js realizes into physical page boxes.
7
+ *
8
+ * Behavior SPEC: `docs/document-model.md`; token set + page model:
9
+ * `docs/dom-contract.md`. Reimplemented cleanly in TS (light-only output).
10
+ */
11
+ import { fontPreset } from './fonts.js';
12
+ /* ─────────────────────────── helpers ─────────────────────────── */
13
+ /** Escape a user string for safe use inside a CSS `content: "..."` value. */
14
+ function cssString(raw) {
15
+ return '"' + raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
16
+ }
17
+ /** Named page sizes pass straight through; a custom string passes through too. */
18
+ function pageSizeValue(size) {
19
+ return size.trim();
20
+ }
21
+ /** The margin-box at-rule name for a page-number / header / footer position. */
22
+ function marginBoxName(pos) {
23
+ switch (pos) {
24
+ case 'header-left':
25
+ return '@top-left';
26
+ case 'header-center':
27
+ return '@top-center';
28
+ case 'header-right':
29
+ return '@top-right';
30
+ case 'footer-left':
31
+ return '@bottom-left';
32
+ case 'footer-center':
33
+ return '@bottom-center';
34
+ case 'footer-right':
35
+ return '@bottom-right';
36
+ default:
37
+ return null;
38
+ }
39
+ }
40
+ /** The `content` expression for a page-number style. */
41
+ function pageNumberExpr(style) {
42
+ switch (style) {
43
+ case 'simple':
44
+ return 'counter(page)';
45
+ case 'page-n':
46
+ return '"Page " counter(page)';
47
+ case 'page-n-of-N':
48
+ return '"Page " counter(page) " of " counter(pages)';
49
+ case 'n-of-N':
50
+ return 'counter(page) " of " counter(pages)';
51
+ case 'n-slash-N':
52
+ return 'counter(page) " / " counter(pages)';
53
+ case 'dash-n-dash':
54
+ return '"— " counter(page) " —"';
55
+ case 'brackets':
56
+ return '"[" counter(page) "]"';
57
+ case 'parentheses':
58
+ return '"(" counter(page) ")"';
59
+ default:
60
+ return 'counter(page)';
61
+ }
62
+ }
63
+ /* ─────────────────────────── builder ─────────────────────────── */
64
+ /**
65
+ * Build the full page CSS string for a document. Pure: depends only on `s`.
66
+ */
67
+ export function buildPageCss(s) {
68
+ // Font family is explicit only when the user/template set fontFamily or
69
+ // fontPreset; otherwise leave it to the theme (or base.css fallback).
70
+ const bodyFamily = s.fontFamily ?? (s.fontPreset ? fontPreset(s.fontPreset).family : undefined);
71
+ const headingFamily = s.fontHeadingPreset ? fontPreset(s.fontHeadingPreset).family : bodyFamily;
72
+ const marginBoxFamily = s.fontMarginBoxPreset
73
+ ? fontPreset(s.fontMarginBoxPreset).family
74
+ : (bodyFamily ?? 'system-ui, "Segoe UI", Helvetica, Arial, sans-serif');
75
+ const headerRuleColor = s.headerRuleColor || s.decorationColor;
76
+ const footerRuleColor = s.footerRuleColor || s.decorationColor;
77
+ // Collect the margin-box declarations for the @page rule. Each value is the
78
+ // inner CSS (content + any per-box styling) keyed by margin-box at-rule.
79
+ const boxes = new Map();
80
+ const addBox = (name, decl) => {
81
+ const list = boxes.get(name);
82
+ if (list)
83
+ list.push(decl);
84
+ else
85
+ boxes.set(name, [decl]);
86
+ };
87
+ // Static running headers/footers.
88
+ const headerFs = `font-size: ${s.headerFontSize}pt;`;
89
+ const footerFs = `font-size: ${s.footerFontSize}pt;`;
90
+ const addStatic = (name, text, fontSize) => {
91
+ if (text && text.length > 0) {
92
+ addBox(name, `content: ${cssString(text)}; ${fontSize}`);
93
+ }
94
+ };
95
+ addStatic('@top-left', s.headerLeft, headerFs);
96
+ addStatic('@top-center', s.headerCenter, headerFs);
97
+ addStatic('@top-right', s.headerRight, headerFs);
98
+ addStatic('@bottom-left', s.footerLeft, footerFs);
99
+ addStatic('@bottom-center', s.footerCenter, footerFs);
100
+ addStatic('@bottom-right', s.footerRight, footerFs);
101
+ // Page number → its margin box. A page number wins the `content` of its box
102
+ // (so it isn't double-declared with a static header/footer in the same box).
103
+ const pnBox = marginBoxName(s.pageNumberPosition);
104
+ if (pnBox && s.pageNumberPosition !== 'none') {
105
+ let expr = pageNumberExpr(s.pageNumberStyle);
106
+ // With clean front matter the body is renumbered from 1. paged.js' own page
107
+ // counter / counter-reset is unreliable across later page breaks, so the
108
+ // engine numbers body pages in JS after layout and feeds the values through
109
+ // CSS vars: --orz-pageno (this page's body number, set per page) and
110
+ // --orz-body-pages (body total = physical − front-matter pages). Both fall
111
+ // back to the native counters when unset (no front matter / pre-measure).
112
+ if (s.frontMatterClean) {
113
+ expr = expr
114
+ .replace(/counter\(pages\)/g, 'var(--orz-body-pages, counter(pages))')
115
+ .replace(/counter\(page\)/g, 'var(--orz-pageno, counter(page))');
116
+ }
117
+ const isHeader = s.pageNumberPosition.startsWith('header-');
118
+ const fontSize = isHeader ? headerFs : footerFs;
119
+ boxes.set(pnBox, [`content: ${expr}; ${fontSize}`]);
120
+ }
121
+ // Header/footer rule lines: drawn via a margin-box border. A rule applies to
122
+ // the whole edge, so attach it to the center box (always realized by paged.js).
123
+ if (s.headerRule) {
124
+ const name = '@top-center';
125
+ const list = boxes.get(name) ?? [];
126
+ list.push(`border-bottom: 0.5pt solid ${headerRuleColor};`);
127
+ boxes.set(name, list);
128
+ }
129
+ if (s.footerRule) {
130
+ const name = '@bottom-center';
131
+ const list = boxes.get(name) ?? [];
132
+ list.push(`border-top: 0.5pt solid ${footerRuleColor};`);
133
+ boxes.set(name, list);
134
+ }
135
+ // Assemble @page margin-box rules.
136
+ const marginBoxRules = [];
137
+ for (const [name, decls] of boxes) {
138
+ marginBoxRules.push(` ${name} {\n ${decls.join('\n ')}\n }`);
139
+ }
140
+ const margin = `${s.marginTop}mm ${s.marginRight}mm ${s.marginBottom}mm ${s.marginLeft}mm`;
141
+ const pageRule = `@page {\n` +
142
+ ` size: ${pageSizeValue(s.pageSize)};\n` +
143
+ ` margin: ${margin};\n` +
144
+ (marginBoxRules.length ? marginBoxRules.join('\n') + '\n' : '') +
145
+ `}`;
146
+ // First-page header/footer suppression.
147
+ const firstPageDecls = [];
148
+ if (s.firstPageHideHeader) {
149
+ firstPageDecls.push(' @top-left { content: none }', ' @top-center { content: none }', ' @top-right { content: none }');
150
+ }
151
+ if (s.firstPageHideFooter || s.firstPageSkipNumber) {
152
+ firstPageDecls.push(' @bottom-left { content: none }', ' @bottom-center { content: none }', ' @bottom-right { content: none }');
153
+ }
154
+ const firstPageRule = firstPageDecls.length
155
+ ? `@page:first {\n${firstPageDecls.join('\n')}\n}`
156
+ : '';
157
+ // Front-matter "clean" mode: every `placement: page` page (title / abstract /
158
+ // toc) is rendered on the named page `orz-front` (paged.js tags it with the
159
+ // class `.pagedjs_orz-front_page`). Hide its margin rows to strip the running
160
+ // header, footer, and page number — done via the page class, NOT `@page
161
+ // orz-front { @top-* { content: none } }`, because paged.js leaks that
162
+ // `content: none` onto the base @page and blanks every page's chrome. Body
163
+ // renumbering is done in JS (per-page --orz-pageno), so no counter-reset here.
164
+ const frontMatterRule = s.frontMatterClean
165
+ ? `.pagedjs_orz-front_page .pagedjs_margin-top,\n` +
166
+ `.pagedjs_orz-front_page .pagedjs_margin-bottom { display: none; }`
167
+ : '';
168
+ // :root design tokens (dom-contract).
169
+ const rootVars = [
170
+ ` --page-size: ${pageSizeValue(s.pageSize)};`,
171
+ ` --margin-t: ${s.marginTop}mm;`,
172
+ ` --margin-b: ${s.marginBottom}mm;`,
173
+ ` --margin-l: ${s.marginLeft}mm;`,
174
+ ` --margin-r: ${s.marginRight}mm;`,
175
+ ` --font-margin-box: ${marginBoxFamily};`,
176
+ ` --font-size: ${s.fontSize}pt;`,
177
+ ` --line-height: ${s.lineHeight};`,
178
+ ];
179
+ // Look tokens: emit only when explicitly set, so a theme's own font / accent /
180
+ // page background apply by default and an explicit setting still wins (this
181
+ // sheet is added last). header/footer rules follow the accent when unset.
182
+ if (bodyFamily)
183
+ rootVars.push(` --font-body: ${bodyFamily};`);
184
+ if (headingFamily)
185
+ rootVars.push(` --font-heading: ${headingFamily};`);
186
+ if (s.decorationColor)
187
+ rootVars.push(` --accent: ${s.decorationColor};`);
188
+ if (s.pageBackground)
189
+ rootVars.push(` --page-bg: ${s.pageBackground};`);
190
+ rootVars.push(` --header-rule: ${headerRuleColor || 'var(--accent, #cccccc)'};`);
191
+ rootVars.push(` --footer-rule: ${footerRuleColor || 'var(--accent, #cccccc)'};`);
192
+ const rootRule = `:root {\n${rootVars.join('\n')}\n}`;
193
+ // Body / element base styling that consumes the tokens.
194
+ const baseRule = `.orz-doc {\n` +
195
+ ` font-family: var(--font-body);\n` +
196
+ ` font-size: var(--font-size);\n` +
197
+ ` line-height: var(--line-height);\n` +
198
+ `}\n` +
199
+ `.pagedjs_margin-top, .pagedjs_margin-bottom,\n` +
200
+ `.pagedjs_margin-top-left, .pagedjs_margin-top-center, .pagedjs_margin-top-right,\n` +
201
+ `.pagedjs_margin-bottom-left, .pagedjs_margin-bottom-center, .pagedjs_margin-bottom-right {\n` +
202
+ ` font-family: var(--font-margin-box);\n` +
203
+ `}`;
204
+ // Layout-behavior CSS.
205
+ const layout = [];
206
+ if (s.limitImageToPage) {
207
+ layout.push('img { max-width: 100%; max-height: 100%; }');
208
+ }
209
+ if (s.keepImageTogether) {
210
+ layout.push('img, figure { break-inside: avoid; }');
211
+ }
212
+ if (s.repeatTableHeader) {
213
+ layout.push('thead { display: table-header-group; }');
214
+ }
215
+ if (s.avoidTableRowBreaks) {
216
+ layout.push('tr { break-inside: avoid; }');
217
+ }
218
+ const layoutRule = layout.join('\n');
219
+ return [
220
+ pageRule,
221
+ firstPageRule,
222
+ frontMatterRule,
223
+ rootRule,
224
+ baseRule,
225
+ layoutRule,
226
+ s.customCss ?? '',
227
+ ]
228
+ .filter((part) => part.length > 0)
229
+ .join('\n\n');
230
+ }