@quario/docx 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.
package/lib/style.js ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * This target's reading of the closed style vocabulary — the counterpart of the
3
+ * CSS table in the HTML target, the points-and-colours module in the layout,
4
+ * and `style.js` in the XLSX target. Declarations are lenient at render: a
5
+ * value of the wrong shape contributes nothing rather than reaching the
6
+ * document.
7
+ *
8
+ * Everything here is a property *string*, in the order the schema fixes.
9
+ * `CT_RPr`, `CT_PPrBase`, `CT_TcPr` and the border containers are sequences,
10
+ * not bags, and a reader handed their children out of order may offer to repair
11
+ * the file — so the order below is the contract, and the one place to check
12
+ * when a declaration is added.
13
+ *
14
+ * `finite`/`HEX` are restated per target on purpose: the coercions are each
15
+ * target's own edge, never shared engine code.
16
+ */
17
+
18
+ import { twips } from "./page.js";
19
+ import { esc } from "./xml.js";
20
+
21
+ let HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
22
+
23
+ // A border container's sides, in the order `CT_PBdr` and `CT_TcBorders` both
24
+ // declare them -- which is *not* the clockwise order the authoring surface
25
+ // names them in. Each carries the declaration names it reads and the element
26
+ // name it writes, built once: `borders` runs per paragraph and per cell, and
27
+ // rebuilding four key strings per side per call is most of the garbage this
28
+ // module makes.
29
+ let SIDES = ["Top", "Left", "Bottom", "Right"].map((side) => ({
30
+ tag: side.toLowerCase(),
31
+ width: "border" + side + "Width",
32
+ line: "border" + side + "Style",
33
+ color: "border" + side + "Color",
34
+ pad: "padding" + side,
35
+ }));
36
+
37
+ /** @type {Record<string, string>} */
38
+ let LINE = { solid: "single", dashed: "dashed", dotted: "dotted" };
39
+
40
+ // The generic families as the names all three readers ship with; any other name
41
+ // is written on the run for the reader to substitute, never embedded.
42
+ /** @type {Record<string, string>} */
43
+ let FAMILY = { sans: "Arial", serif: "Times New Roman", mono: "Courier New" };
44
+
45
+ /** @type {Record<string, string>} */
46
+ let JUSTIFY = { left: "left", center: "center", right: "right" };
47
+ // Word's word for the middle is `center`; the authoring surface says `middle`.
48
+ /** @type {Record<string, string>} */
49
+ let VERTICAL = { top: "top", middle: "center", bottom: "bottom" };
50
+
51
+ /** A declared colour as the six hex digits Word writes, or null.
52
+ * @type {(value: any) => string | null} */
53
+ // fallow-ignore-next-line code-duplication -- each target reads a declared hex into its own type; none may import a sibling, and the engine's stream is all they share
54
+ let rgb = (value) => {
55
+ let match = typeof value === "string" && HEX.exec(value);
56
+ if (!match) return null;
57
+ // `#abc` is `#aabbcc`.
58
+ let digits = match[1].length === 3 ? match[1].replace(/./g, (d) => d + d) : match[1];
59
+ return digits.toUpperCase();
60
+ };
61
+
62
+ /** @type {(value: any) => number | null} */
63
+ let points = (value) => (Number.isFinite(value) && value >= 0 ? value : null);
64
+ /** A flag's on-element, its off-element, or nothing. Exact `true` and exact
65
+ * `false` are declarations; anything else leaves the layer below in place.
66
+ * @type {(value: any, tag: string) => string} */
67
+ let flag = (value, tag) =>
68
+ value === true ? `<w:${tag}/>` : value === false ? `<w:${tag} w:val="0"/>` : "";
69
+
70
+ /** @type {(value: any) => string} */
71
+ let underline = (value) =>
72
+ value === true ? '<w:u w:val="single"/>' : value === false ? '<w:u w:val="none"/>' : "";
73
+
74
+ /**
75
+ * The face a declared family names. A generic maps to the name all three
76
+ * readers ship with; any other is the author's own string, written for the
77
+ * reader to substitute and **escaped** — it is the one value in this module
78
+ * that is not a number, a hex colour or a closed name, so it is the one that
79
+ * reaches an attribute as the author wrote it (root CLAUDE.md, constraint 4).
80
+ *
81
+ * @type {(family: any) => string}
82
+ */
83
+ let face = (family) => {
84
+ if (typeof family !== "string" || !family) return "";
85
+ let key = family.toLowerCase();
86
+ // Own-key lookup: `constructor` must not resolve an inherited member.
87
+ let name = esc(Object.hasOwn(FAMILY, key) ? FAMILY[key] : family);
88
+ return `<w:rFonts w:ascii="${name}" w:hAnsi="${name}" w:cs="${name}"/>`;
89
+ };
90
+
91
+ /** @type {(value: any) => string} */
92
+ let halves = (value) => {
93
+ let size = points(value);
94
+ if (!size) return "";
95
+ // Half-points, and a size of zero is not a size.
96
+ let half = Math.round(size * 2);
97
+ return `<w:sz w:val="${half}"/><w:szCs w:val="${half}"/>`;
98
+ };
99
+
100
+ /** A container's own shading, from its `background`.
101
+ * @type {(style: any) => string} */
102
+ export let shading = (style) => shade(style?.background);
103
+
104
+ /** @type {(value: any) => string} */
105
+ let shade = (value) => {
106
+ let fill = rgb(value);
107
+ return fill ? `<w:shd w:val="clear" w:color="auto" w:fill="${fill}"/>` : "";
108
+ };
109
+
110
+ /** @type {(value: any) => string} */
111
+ let tint = (value) => {
112
+ let color = rgb(value);
113
+ return color ? `<w:color w:val="${color}"/>` : "";
114
+ };
115
+
116
+ /**
117
+ * The run half of a resolved style, in `CT_RPr` order. Empty where the style
118
+ * declares nothing this target reads, so a run with no properties emits no
119
+ * `rPr` at all.
120
+ *
121
+ * `uppercase` is not here: Word's `caps` is a presentation the other two
122
+ * readers disagree about, so this target capitalises the string instead —
123
+ * Unicode default case mapping, never the host's locale, exactly as the PDF
124
+ * target does for its own reason (`text.js`).
125
+ *
126
+ * `boxed` is the same question `paraProps` asks below and means the same thing:
127
+ * *is this element the one that draws the box?* The box belongs to the
128
+ * innermost container that can hold one, so a `background` on a cell or an item
129
+ * is that container's shading and a run draws none — which is why the default
130
+ * here is `false` and there is `true`. Only a
131
+ * [styled run](../../../SCHEMA.md#cell-values) has nothing but the run to draw
132
+ * its own on.
133
+ *
134
+ * @type {(style: any, boxed?: boolean) => string}
135
+ */
136
+ export let runProps = (style, boxed = false) =>
137
+ !style
138
+ ? ""
139
+ : face(style.family) +
140
+ flag(style.bold, "b") +
141
+ flag(style.italic, "i") +
142
+ flag(style.strikethrough, "strike") +
143
+ tint(style.color) +
144
+ halves(style.size) +
145
+ underline(style.underline) +
146
+ (boxed ? shade(style.background) : "");
147
+
148
+ /** Whether a style asks for capitals. `false` is a declaration that turns an
149
+ * outer layer's capitals off, which is the absence of the transform either way.
150
+ * @type {(style: any) => boolean} */
151
+ export let capitals = (style) => style?.uppercase === true;
152
+
153
+ /** One border side, complete or nothing: a missing name never becomes a
154
+ * reader's default black stroke.
155
+ * @type {(style: any, side: (typeof SIDES)[number]) => string} */
156
+ let edge = (style, side) => {
157
+ let width = points(style[side.width]);
158
+ let line = lineOf(style[side.line]);
159
+ let color = rgb(style[side.color]);
160
+ if (!width || !line || !color) return "";
161
+ // `w:sz` is eighths of a point, and Word clamps it to 2..96.
162
+ let eighths = Math.min(96, Math.max(2, Math.round(width * 8)));
163
+ return (
164
+ `<w:${side.tag} w:val="${line}" w:sz="${eighths}"` +
165
+ ` w:space="${spaceOf(style, side)}" w:color="${color}"/>`
166
+ );
167
+ };
168
+
169
+ /** @type {(named: any) => string | null} */
170
+ let lineOf = (named) =>
171
+ typeof named === "string" && Object.hasOwn(LINE, named) ? LINE[named] : null;
172
+
173
+ // A paragraph has no inset of its own, so an item's padding is approximated as
174
+ // the space between its border and its text -- which is what the declaration
175
+ // means on a surface that can draw it. Word writes that space in whole points,
176
+ // 0 to 31, so a padding outside the range is clamped rather than dropped.
177
+ /** @type {(style: any, side: (typeof SIDES)[number]) => number} */
178
+ let spaceOf = (style, side) => Math.min(31, Math.round(points(style[side.pad]) || 0));
179
+
180
+ /** @type {(style: any, wrap: string) => string} */
181
+ export let borders = (style, wrap) => {
182
+ let out = "";
183
+ for (let side of SIDES) out += edge(style || {}, side);
184
+ return out && `<w:${wrap}>${out}</w:${wrap}>`;
185
+ };
186
+
187
+ /** @type {(value: any, tag: string, map: Record<string, string>) => string} */
188
+ let named = (value, tag, map) =>
189
+ typeof value === "string" && Object.hasOwn(map, value) ? `<w:${tag} w:val="${map[value]}"/>` : "";
190
+
191
+ /** @type {(style: any) => string} */
192
+ let spacing = (style) => {
193
+ let attrs = gap("before", style.spaceBefore) + gap("after", style.spaceAfter);
194
+ return attrs && "<w:spacing" + attrs + "/>";
195
+ };
196
+
197
+ /** @type {(name: string, value: any) => string} */
198
+ let gap = (name, value) => {
199
+ let own = points(value);
200
+ return own === null ? "" : ` w:${name}="${twips(own)}"`;
201
+ };
202
+
203
+ /**
204
+ * The paragraph half, in `CT_PPrBase` order.
205
+ *
206
+ * `boxed` is the same question `runProps` asks above. The box is drawn by the
207
+ * innermost container that can hold one: outside a table that is the paragraph,
208
+ * as borders and shading, with padding as the border space — the approximation
209
+ * `quario-vkgi.4` decided, because a paragraph has no inset. Inside a table cell
210
+ * it is the *cell*, which has one, so the paragraph there passes `boxed: false`
211
+ * or the reader draws two rules.
212
+ *
213
+ * @type {(style: any, boxed?: boolean) => string}
214
+ */
215
+ export let paraProps = (style, boxed = true) =>
216
+ !style
217
+ ? ""
218
+ : (boxed ? borders(style, "pBdr") + shade(style.background) : "") +
219
+ spacing(style) +
220
+ named(style.align, "jc", JUSTIFY);
221
+
222
+ /**
223
+ * The cell half, in `CT_TcPr` order, minus the width and span the table writer
224
+ * owns. A cell *does* have an inset, so padding is exact here rather than
225
+ * approximated, and the target's own padding fills the sides the author left
226
+ * unnamed — a named `0` beating it, which is what makes it a declaration
227
+ * (`docs/adr/0040`).
228
+ *
229
+ * @type {(style: any, pad: Record<string, number>) => string}
230
+ */
231
+ export let cellProps = (style, pad) =>
232
+ borders(style || {}, "tcBorders") +
233
+ shade(style?.background) +
234
+ margins(style, pad) +
235
+ named(style?.valign, "vAlign", VERTICAL);
236
+
237
+ // The cell inset a style that names none resolves to: the target's own padding,
238
+ // which is one object for a whole render, so the element is built once per
239
+ // padding rather than once per cell. A table of five thousand rows writes the
240
+ // same twenty-odd bytes twenty-five thousand times otherwise.
241
+ /** @type {Map<Record<string, number>, string>} */
242
+ let PLAIN = new Map();
243
+
244
+ /** @type {(style: any, pad: Record<string, number>) => string} */
245
+ let margins = (style, pad) =>
246
+ SIDES.some((side) => points(style?.[side.pad]) !== null)
247
+ ? `<w:tcMar>${SIDES.map((side) => inset(side.tag, points(style?.[side.pad]) ?? pad[side.tag])).join("")}</w:tcMar>`
248
+ : plainMargins(pad);
249
+
250
+ /** @type {(pad: Record<string, number>) => string} */
251
+ let plainMargins = (pad) => {
252
+ let made = PLAIN.get(pad);
253
+ if (made) return made;
254
+ made = `<w:tcMar>${SIDES.map((side) => inset(side.tag, pad[side.tag])).join("")}</w:tcMar>`;
255
+ PLAIN.set(pad, made);
256
+ return made;
257
+ };
258
+
259
+ /** @type {(tag: string, value: number) => string} */
260
+ let inset = (tag, value) => `<w:${tag} w:w="${twips(value)}" w:type="dxa"/>`;
261
+
262
+ /**
263
+ * Style blocks layer outward-in: the enclosing block under the inner one. A
264
+ * `false` in the inner block is a declaration and wins, which a plain overwrite
265
+ * already gives — the engine has resolved everything else before the stream.
266
+ *
267
+ * @type {(under: any, over: any) => any}
268
+ */
269
+ export let under = (outer, inner) =>
270
+ outer ? (inner ? { ...outer, ...inner } : outer) : inner || null;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The `styles.xml` part, written on every render and carrying two things.
3
+ * Named for the part rather than for the vocabulary: `style.js` beside it is
4
+ * this target's reading of what an author declares, and the two are imported
5
+ * together often enough that one letter between them would be a trap.
6
+ *
7
+ * `docDefaults` is the base every run and paragraph inherits: the sans
8
+ * generic's own name, the report default's size, and the line spacing the
9
+ * other targets lay out at. It is the one place this target states a look
10
+ * through the style mechanism at all.
11
+ *
12
+ * The nine heading styles are what put a group in a reader's navigation pane,
13
+ * and each reader asks a different question of them. `<w:name w:val="heading
14
+ * 1"/>` is what binds Word's own built-in — the `styleId` is a local label and
15
+ * the name is the binding — `outlineLvl` is what the navigation pane reads,
16
+ * and Google Docs maps by `styleId`, which is why all three are written. The
17
+ * `qFormat` beside them is narrower than the other three: it puts the style in
18
+ * a reader's gallery, and is not what makes any of them recognise it.
19
+ *
20
+ * **`Normal` is deliberately empty.** The [report default](../../../SCHEMA.md)
21
+ * and every declaration an author writes are direct formatting on the runs and
22
+ * paragraphs that wear them, never folded in here: a look that depends on a
23
+ * style lookup is a look three readers may resolve three different ways, and
24
+ * this target's promise is the geometry and typography of the preview.
25
+ */
26
+ import { W, XML } from "./xml.js";
27
+
28
+ // The base. Arial is the name the sans generic maps to; 10 pt is `sz` in
29
+ // half-points; 1.4 line spacing is `line` in 240ths of a line, `auto` so a
30
+ // larger run still fits.
31
+ const FAMILY = "Arial";
32
+ const SIZE = 20;
33
+ const LINE = 336;
34
+
35
+ /** Word caps the outline at nine levels, and so does the deepest style here. */
36
+ const LEVELS = 9;
37
+
38
+ /** @type {(level: number) => string} */
39
+ let heading = (level) =>
40
+ `<w:style w:type="paragraph" w:styleId="Heading${level}">` +
41
+ `<w:name w:val="heading ${level}"/>` +
42
+ '<w:basedOn w:val="Normal"/>' +
43
+ "<w:qFormat/>" +
44
+ `<w:pPr><w:outlineLvl w:val="${level - 1}"/></w:pPr>` +
45
+ "<w:rPr><w:b/></w:rPr>" +
46
+ "</w:style>";
47
+
48
+ /** The part, identical on every render: nothing here reads the report. */
49
+ export const STYLES =
50
+ XML +
51
+ `<w:styles ${W}>` +
52
+ "<w:docDefaults>" +
53
+ "<w:rPrDefault><w:rPr>" +
54
+ `<w:rFonts w:ascii="${FAMILY}" w:hAnsi="${FAMILY}" w:cs="${FAMILY}"/>` +
55
+ `<w:sz w:val="${SIZE}"/><w:szCs w:val="${SIZE}"/>` +
56
+ "</w:rPr></w:rPrDefault>" +
57
+ "<w:pPrDefault><w:pPr>" +
58
+ `<w:spacing w:line="${LINE}" w:lineRule="auto"/>` +
59
+ "</w:pPr></w:pPrDefault>" +
60
+ "</w:docDefaults>" +
61
+ '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>' +
62
+ Array.from({ length: LEVELS }, (_, i) => heading(i + 1)).join("") +
63
+ "</w:styles>";
64
+
65
+ /**
66
+ * The heading style a group header takes at this nesting depth, capped at the
67
+ * deepest one Word draws. Google Docs shows `Heading7` and deeper as body
68
+ * text — a documented best-effort edge, not something a level can fix.
69
+ *
70
+ * @type {(depth: number) => string}
71
+ */
72
+ export let headingAt = (depth) => "Heading" + Math.min(depth + 1, LEVELS);
package/lib/table.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Real Word tables: the table detail, and the [split](../../../SCHEMA.md#split-item)
3
+ * bracket, which is the same thing with no rules and one row.
4
+ *
5
+ * A flow target measures nothing, so the grid is the only place a column width
6
+ * can be settled: declared shares are exact, in twips of the content width, and
7
+ * the columns that declared none divide what is left **evenly** rather than
8
+ * from their content. That is the one approximation `quario-vkgi.4`
9
+ * decided for this row, and it is why the layout is fixed — an automatic one would let the reader re-measure
10
+ * and lose the shares the author wrote.
11
+ *
12
+ * `CT_TblPrBase`, `CT_TrPr` and `CT_TcPr` are sequences like every other
13
+ * container this target writes; the order below is the contract.
14
+ */
15
+ import { borders, cellProps, paraProps, shading, under } from "./style.js";
16
+ import { looking, paragraph, runs } from "./text.js";
17
+
18
+ /** The width of a table, as a fraction of the text column, in fiftieths of a
19
+ * percent — which is how `pct` is spelled. */
20
+ const FULL = '<w:tblW w:w="5000" w:type="pct"/>';
21
+
22
+ // No rules: an unstyled table has the strokes the author declared and no
23
+ // others, which is what the PDF and the worksheet already show. A reader's own
24
+ // table default would be a look nothing in the report asked for.
25
+ /** `CT_TblPrBase` order: the width, then the box, then the layout. */
26
+ const LAYOUT = '<w:tblLayout w:type="fixed"/>';
27
+
28
+ /**
29
+ * The grid, in twips. `shares` are the declared percentages, one per column,
30
+ * `null` where a column declared none; `width` is the content width in points.
31
+ * The engine has already refused a document whose fixed shares leave the
32
+ * width-less columns nothing, so the remainder here is never negative.
33
+ *
34
+ * @type {(shares: (number | null)[], width: number) => number[]}
35
+ */
36
+ export let grid = (shares, width) => {
37
+ let total = Math.round(width * 20);
38
+ /** @type {(number | null)[]} */
39
+ let fixed = shares.map((share) =>
40
+ Number.isFinite(share) ? Math.round((/** @type {number} */ (share) / 100) * total) : null,
41
+ );
42
+ let spare = fixed.filter((w) => w === null).length;
43
+ let taken = 0;
44
+ for (let w of fixed) taken += w ?? 0;
45
+ let left = total - taken;
46
+ let each = spare ? Math.max(0, Math.round(left / spare)) : 0;
47
+ return fixed.map((w) => w ?? each);
48
+ };
49
+
50
+ /** @type {(widths: number[]) => string} */
51
+ let tblGrid = (widths) =>
52
+ "<w:tblGrid>" + widths.map((w) => `<w:gridCol w:w="${w}"/>`).join("") + "</w:tblGrid>";
53
+
54
+ /**
55
+ * One cell. `body` is the block content it holds — a cell must hold at least
56
+ * one paragraph, so an empty one still gets its own.
57
+ *
58
+ * @type {(cell: any, width: number, body: string, pad: Record<string, number>) => string}
59
+ */
60
+ let tc = (cell, width, body, pad) =>
61
+ "<w:tc><w:tcPr>" +
62
+ `<w:tcW w:w="${width}" w:type="dxa"/>` +
63
+ (cell.span > 1 ? `<w:gridSpan w:val="${cell.span}"/>` : "") +
64
+ cellProps(cell.style, pad) +
65
+ "</w:tcPr>" +
66
+ (body || "<w:p/>") +
67
+ "</w:tc>";
68
+
69
+ /**
70
+ * One row. `heading` repeats it after every page break, which is what keeps a
71
+ * long table readable and is the only thing `trPr` carries here.
72
+ *
73
+ * @type {(cells: string, heading?: boolean) => string}
74
+ */
75
+ let tr = (cells, heading = false) =>
76
+ "<w:tr>" + (heading ? "<w:trPr><w:tblHeader/></w:trPr>" : "") + cells + "</w:tr>";
77
+
78
+ /**
79
+ * A table. `style` is the box the table itself wears — a
80
+ * [split](../../../SCHEMA.md#split-item) keeps its own box, unlike a table row
81
+ * whose the engine has already resolved onto the cells, so this is where a
82
+ * split's borders and shading land.
83
+ *
84
+ * @type {(rows: string, widths: number[], style?: any) => string}
85
+ */
86
+ export let tbl = (rows, widths, style = null) =>
87
+ "<w:tbl><w:tblPr>" +
88
+ FULL +
89
+ borders(style, "tblBorders") +
90
+ shading(style) +
91
+ LAYOUT +
92
+ "</w:tblPr>" +
93
+ tblGrid(widths) +
94
+ rows +
95
+ "</w:tbl>";
96
+
97
+ // A split is a borderless table with no padding of its own: it places its slots
98
+ // across the width and nothing more, which is what the other targets draw.
99
+ const ZERO_PAD = { top: 0, left: 0, bottom: 0, right: 0 };
100
+
101
+ /**
102
+ * A [split](../../../SCHEMA.md#split-item) being filled. Its slots arrive as
103
+ * separate events between the bracket, one per slot and in slot order, so the
104
+ * column a slot lands in is simply the next one.
105
+ *
106
+ * The split's own declarations are the layer under each slot's, and its box
107
+ * stays its own — unlike a table row's, which the engine has already resolved
108
+ * onto the cells before any target sees it (SCHEMA.md, "Splits").
109
+ *
110
+ * The body and a page band both fill one, and differ on nothing but what a
111
+ * picture inside a slot is capped at, which is why `content` takes the slot's
112
+ * own width.
113
+ *
114
+ * @type {(slots: any[], width: number, style: any) =>
115
+ * { style: any, slot: (own: any, content: (column: number) => string) => void,
116
+ * close: () => string }}
117
+ */
118
+ export let splitting = (slots, width, style) => {
119
+ let widths = grid(
120
+ slots.map((/** @type {any} */ s) => (Number.isFinite(s.width) ? s.width : null)),
121
+ width,
122
+ );
123
+ let at = 0;
124
+ let cells = "";
125
+ return {
126
+ style,
127
+ slot: (own, content) => {
128
+ let column = widths[at++] || 0;
129
+ // `boxed: false`: the slot's box is the cell's, so the paragraph inside
130
+ // it must not draw a second one.
131
+ cells += tc(
132
+ { span: 1, style: own },
133
+ column,
134
+ paragraph(paraProps(own, false), content(column / 20)),
135
+ ZERO_PAD,
136
+ );
137
+ },
138
+ close: () => tbl(tr(cells), widths, style),
139
+ };
140
+ };
141
+
142
+ /**
143
+ * How wide one cell is, in twips: its own grid column, plus every column it
144
+ * [spans](../../../SCHEMA.md#span). `record` walks the cells in order and keeps
145
+ * the column it has reached, since a span consumes more than one.
146
+ *
147
+ * @type {(widths: number[], at: number, span: number) => number}
148
+ */
149
+ let spanned = (widths, at, span) =>
150
+ widths.slice(at, at + Math.max(1, span || 1)).reduce((sum, w) => sum + w, 0);
151
+
152
+ /**
153
+ * One row of cells, each wearing what the row layers over it and taking the
154
+ * grid columns it [spans](../../../SCHEMA.md#span). `heading` repeats the row
155
+ * after every page break.
156
+ *
157
+ * A table cell can no more hold a live field than a body paragraph can: only a
158
+ * page band binds the `page` anchor, which is why no sequence is named here.
159
+ *
160
+ * @type {(cells: any[], widths: number[], heading: boolean, layer: any,
161
+ * how: { pad: Record<string, number>, intl: any }) => string}
162
+ */
163
+ export let record = (cells, widths, heading, layer, how) => {
164
+ let at = 0;
165
+ let out = "";
166
+ for (let cell of cells) {
167
+ let style = under(layer, cell.style);
168
+ let span = Math.max(1, cell.span || 1);
169
+ out += tc(
170
+ { span: cell.span, style },
171
+ spanned(widths, at, span),
172
+ // `boxed: false`: the cell above draws the box, so the paragraph inside
173
+ // it must not draw a second one.
174
+ paragraph(paraProps(style, false), runs(cell.tokens, looking(style, how.intl))),
175
+ how.pad,
176
+ );
177
+ at += span;
178
+ }
179
+ return tr(out, heading);
180
+ };
package/lib/text.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * A cell's tokens as Word runs, and the paragraph that holds them. The one
3
+ * place this target turns the engine's token stream into markup, so the body
4
+ * and the page bands cannot drift on what a cell reads as.
5
+ *
6
+ * A token the engine tagged `page.number` or `page.total` (CONTEXT.md, "Live
7
+ * field") becomes a Word **field**, which the reader's application recomputes
8
+ * as the document repaginates. Anything computed from a page value is ordinary
9
+ * text, frozen at what the render saw.
10
+ *
11
+ * A cell's [styled runs](../../../SCHEMA.md#cell-values) are the engine's own
12
+ * grouping, taken through `styledRuns` so this target cannot drift from the
13
+ * others on what a run is. An authored newline is a hard break, which is a run
14
+ * of its own rather than a character, and `uppercase` capitalises the string
15
+ * here because Word's `caps` is a presentation the three readers disagree
16
+ * about — Unicode default case mapping, never the host's locale, so the bytes
17
+ * stay reproducible.
18
+ */
19
+ import { display, format, styledRuns } from "quario";
20
+ import { capitals, runProps } from "./style.js";
21
+ import { esc } from "./xml.js";
22
+
23
+ /** One paragraph, its properties already written.
24
+ * @type {(props: string, inner: string) => string} */
25
+ export let paragraph = (props, inner) =>
26
+ "<w:p>" + (props && "<w:pPr>" + props + "</w:pPr>") + inner + "</w:p>";
27
+
28
+ // A hard break, and the newlines the engine's stream may carry (SCHEMA.md,
29
+ // "Layout contract"): CRLF first, so a pair is one break and not two.
30
+ const BREAK = "<w:br/>";
31
+ let NEWLINE = /\r\n|\r|\n/;
32
+
33
+ /** The one run element, with its properties and its text already settled.
34
+ * @type {(text: string, look: string) => string} */
35
+ let piece = (text, look) =>
36
+ "<w:r>" +
37
+ (look && "<w:rPr>" + look + "</w:rPr>") +
38
+ '<w:t xml:space="preserve">' +
39
+ esc(text) +
40
+ "</w:t></w:r>";
41
+
42
+ /**
43
+ * One run's text, or nothing where there is none. An authored newline splits
44
+ * the run so a `<w:br/>` can sit between the halves — a break is an element in
45
+ * Word, not a character, so it cannot ride inside the `<w:t>`.
46
+ *
47
+ * @type {(text: string, look?: string) => string}
48
+ */
49
+ export let run = (text, look = "") => {
50
+ if (!text) return "";
51
+ return text
52
+ .split(NEWLINE)
53
+ .map((line) => piece(line, look))
54
+ .join(BREAK);
55
+ };
56
+
57
+ /**
58
+ * One Word field, with no cached result. The result is deliberately absent:
59
+ * a cached one would be the page number the probe happened to read, which
60
+ * would make the two probe results differ for every band that numbers itself
61
+ * and so put a first-page part on every report. Word and the other readers
62
+ * compute `PAGE` and its two totals while laying the page out regardless.
63
+ *
64
+ * @type {(name: string) => string}
65
+ */
66
+ let field = (name) =>
67
+ '<w:r><w:fldChar w:fldCharType="begin"/></w:r>' +
68
+ '<w:r><w:instrText xml:space="preserve"> ' +
69
+ name +
70
+ ' </w:instrText></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r>';
71
+
72
+ /** The field that gives a sequence's length: the document's, and a section's. */
73
+ export const DOCUMENT_PAGES = "NUMPAGES";
74
+ export const SECTION_PAGES = "SECTIONPAGES";
75
+
76
+ /**
77
+ * A tagged token's field, or nothing where the token is ordinary text.
78
+ * `sequence` is the field that gives the current sequence's length —
79
+ * `NUMPAGES` for the document's, `SECTIONPAGES` inside a section a
80
+ * `reset: "page"` instance opened.
81
+ *
82
+ * @type {(token: any, sequence: string) => string}
83
+ */
84
+ let fieldOf = (token, sequence) =>
85
+ token.field ? field(token.field === "page.number" ? "PAGE" : sequence) : "";
86
+
87
+ /**
88
+ * One ordinary token's text: the cell's `format` declaration applied at the
89
+ * markup edge, falling back to the scalar display rule the whole stack shares,
90
+ * and capitalised where the style asks.
91
+ *
92
+ * @type {(token: any, look: Look) => string}
93
+ */
94
+ let textOf = (token, look) => {
95
+ let text = "literal" in token ? token.literal : shown(token, look);
96
+ return look.caps ? text.toUpperCase() : text;
97
+ };
98
+
99
+ /** One interpolated value as text: the cell's `format` declaration at the
100
+ * markup edge, falling back to the scalar rule the whole stack shares.
101
+ * @type {(token: any, look: Look) => string} */
102
+ let shown = (token, look) => format(token.value, look.style, look.intl) ?? display(token.value);
103
+
104
+ /**
105
+ * What a stretch of text is written with: the resolved style it wears, the run
106
+ * properties that style became, whether it is capitalised, the instance's
107
+ * `format` configuration, and which field gives the current sequence's length.
108
+ * Carried together because every one of them is read for every token, and
109
+ * derived once per run rather than per token — `sequence` included, which is
110
+ * why nothing below it takes one as an argument.
111
+ *
112
+ * @typedef {{ style: any, props: string, caps: boolean, intl: any,
113
+ * sequence: string }} Look
114
+ */
115
+
116
+ /** @type {(style: any, intl: any, sequence?: string, boxed?: boolean) => Look} */
117
+ export let looking = (style, intl, sequence = DOCUMENT_PAGES, boxed = false) => ({
118
+ style,
119
+ props: runProps(style, boxed),
120
+ caps: capitals(style),
121
+ intl,
122
+ sequence,
123
+ });
124
+
125
+ /**
126
+ * A cell's tokens as Word runs. The engine's `styledRuns` is the grouping, so
127
+ * a styled run's own resolved style replaces the cell's for its stretch — the
128
+ * two are already composed on the stream — and a tagged token stays a field
129
+ * whatever style it wears.
130
+ *
131
+ * @type {(tokens: any[], look: Look) => string}
132
+ */
133
+ export let runs = (tokens, look) =>
134
+ styledRuns(tokens)
135
+ .map((styled) => {
136
+ let own = styled.style ? looking(styled.style, look.intl, look.sequence, true) : look;
137
+ return styled.tokens
138
+ .map((token) => fieldOf(token, own.sequence) || run(textOf(token, own), own.props))
139
+ .join("");
140
+ })
141
+ .join("");