@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/body.js ADDED
@@ -0,0 +1,340 @@
1
+ /**
2
+ * The document body: the paragraphs, tables and pictures a report resolves to,
3
+ * and the two hints that move the document rather than occupy it.
4
+ *
5
+ * **Sections.** A `reset: "page"` instance starts a fresh page-number sequence,
6
+ * which Word spells as a section. A section break lives in the properties of
7
+ * the paragraph the section ends on, so it is folded into the last one the
8
+ * section held; a section that held none writes a paragraph for it, because
9
+ * there is nowhere else for the break to go. An instance with nothing at all
10
+ * before it does not end a section — it *is* the document's first one, which is
11
+ * SCHEMA.md's "an instance already at the top of a page does not force an empty
12
+ * page".
13
+ *
14
+ * **Page breaks.** `break: "page"` is `pageBreakBefore` on the instance's first
15
+ * paragraph, skipped where the instance also declared `reset`, whose section has
16
+ * already turned the page, and where it has nothing before it at all.
17
+ *
18
+ * **Page columns.** A count on the report columns the body's own sections; a
19
+ * count on a group wraps each instance in *continuous* section breaks, so the
20
+ * columns start and stop where the instance does without turning a page. Word
21
+ * and Writer draw any count; Google Docs draws at most three, which is the
22
+ * matrix's approximation here.
23
+ *
24
+ * **Headings.** A group header's *first* item takes `Heading{depth + 1}`, the
25
+ * way the PDF titles its outline entry by the first header text; the rest are
26
+ * plain paragraphs. The report header stays out of the outline entirely — the
27
+ * outline mirrors the group tree — so it wears its role default and no style.
28
+ *
29
+ * **Layers.** Outward-in: this target's baseline (`docDefaults`, so nothing is
30
+ * written per run), the report default, the band-role default, then the node's
31
+ * own — SCHEMA.md, "Style declarations". A `false` in an inner layer is a
32
+ * declaration and wins, which a plain overwrite already gives.
33
+ */
34
+ import { headingAt } from "./stylepart.js";
35
+ import { paraProps, under } from "./style.js";
36
+ import { grid, record, splitting, tbl } from "./table.js";
37
+ import { describe, drawing, idOf, sizeOf } from "./picture.js";
38
+ import { EMPTY_CTX } from "./furniture.js";
39
+ import { looking, paragraph, runs } from "./text.js";
40
+
41
+ // The band roles this target supplies a default for, and the only look it puts
42
+ // on a report that declared nothing. ADR 0014: a target supplies defaults where
43
+ // its consumer has no seam to supply them, and a document is final on open.
44
+ // `test/omakase-defaults.test.js` holds this copy and the other two in
45
+ // agreement; it is not shared code, and that is the ADR's point.
46
+ /** @type {Record<string, any>} */
47
+ const ROLES = {
48
+ "report-header": { bold: true, size: 14 },
49
+ "group-header": { bold: true },
50
+ };
51
+
52
+ // The cell padding this target supplies on a side the author did not name: 6 pt
53
+ // across, 2 pt down, the layout's own numbers. A named `0` beats it, which is
54
+ // what makes the declaration a declaration (`docs/adr/0040`).
55
+ const PAD = { top: 2, left: 6, bottom: 2, right: 6 };
56
+
57
+ // The structural gap a group instance opens with, so groups read as blocks
58
+ // without authored margins, and the band gap a table closes with. Half a line
59
+ // of the base type at the lead `stylepart.js` writes -- 0.5 * 1.4 * 10 -- which
60
+ // is the layout's own `instanceGap`, in points.
61
+ const GAP = 7;
62
+
63
+ // The space Word leaves between page columns, in twips: its own default, and
64
+ // what `index.js` writes into every `cols`.
65
+ const GUTTER = 708;
66
+
67
+ // Nothing here names a sequence: a body cell can never hold a live field,
68
+ // because only a page band binds the `page` anchor, so `looking` defaults to
69
+ // the document's own and nothing below carries one.
70
+
71
+ /**
72
+ * One report's body, built as the walk hands it events.
73
+ *
74
+ * @param {(kind: any) => any} section Opens a section of the given kind and
75
+ * returns it; the caller owns what a section knows about its furniture.
76
+ */
77
+ export let bodyOf = (section) => {
78
+ /** @type {{ props: string, inner: string, closes?: any, raw?: boolean }[]} */
79
+ let blocks = [];
80
+ /** @type {any[]} */
81
+ let sections = [];
82
+ // What the next paragraph is still owed. A break is markup; the structural
83
+ // gap is a *style layer* rather than a `<w:spacing>` of its own, because a
84
+ // paragraph carries one `spacing` element and the author may have declared
85
+ // it -- so the gap layers under their own `spaceBefore`, which then wins.
86
+ let owed = { broken: false, gapped: false };
87
+ // The heading a group instance's *first* header item is still owed, set when
88
+ // the instance opens and taken by whichever item claims it. One string rather
89
+ // than a depth and a flag: the two only ever moved together.
90
+ let heading = "";
91
+ /** What a render carries that the events do not: the report default, the
92
+ * instance's `format` configuration, the text column and the picture
93
+ * registry. Settled once, on the opening event, and read-only after —
94
+ * `furniture.js`'s `Ctx`, so the two writers describe a render one way.
95
+ * @type {import("./furniture.js").Ctx} */
96
+ let ctx = EMPTY_CTX;
97
+ /** The table being filled, if any: its grid, and the rows written so far. */
98
+ /** @type {{ widths: number[], rows: string } | null} */
99
+ let table = null;
100
+ /** The split being filled, if any. Splits never nest, so one is enough. */
101
+ /** @type {ReturnType<typeof splitting> | null} */
102
+ let split = null;
103
+
104
+ // `CT_PPr` is a sequence and not a bag: `pStyle` is its first child,
105
+ // `pageBreakBefore` its fourth, and everything `paraProps` writes comes after
106
+ // both. The order here is the contract.
107
+ /** @type {(props: string, style: any, inner: string) => void} */
108
+ let push = (props, style, inner) => {
109
+ let look = owed.gapped ? under({ spaceBefore: GAP }, style) : style;
110
+ blocks.push({
111
+ props: props + (owed.broken ? "<w:pageBreakBefore/>" : "") + paraProps(look),
112
+ inner,
113
+ });
114
+ owed = { broken: false, gapped: false };
115
+ };
116
+
117
+ /** A block that is not a paragraph -- a table -- which carries no properties
118
+ * and so cannot take what the next paragraph is owed.
119
+ * @type {(inner: string) => void} */
120
+ let block = (inner) => {
121
+ blocks.push({ props: "", inner, raw: true });
122
+ };
123
+
124
+ let sectionStart = 0;
125
+ /** @type {(kind: any) => void} */
126
+ let open = (kind) => {
127
+ sectionStart = blocks.length;
128
+ sections.push(section(kind));
129
+ };
130
+
131
+ /** The report's own page-column count, and the depths of the instances that
132
+ * opened a columned section, so `group-end` knows whose to close. */
133
+ let columns = 1;
134
+ /** @type {number[]} */
135
+ let columned = [];
136
+ /** @type {(value: any) => number} */
137
+ let counted = (value) => (Number.isInteger(value) && value > 1 ? value : 0);
138
+
139
+ /** @type {(own: import("./furniture.js").Ctx, event: any) => void} */
140
+ let start = (own, event) => {
141
+ ctx = own;
142
+ columns = counted(event.columns) || 1;
143
+ open({ columns });
144
+ };
145
+
146
+ /** The style a node wears: the report default, its role's, then its own. */
147
+ /** @type {(event: any) => any} */
148
+ let styleOf = (event) =>
149
+ under(
150
+ under(ctx.base, Object.hasOwn(ROLES, event.role) ? ROLES[event.role] : null),
151
+ event.style,
152
+ );
153
+
154
+ /** @type {(event: any) => void} */
155
+ let breakBefore = (event) => {
156
+ // oxlint-disable-next-line no-unused-expressions
157
+ owed.broken ||= event.break === "page" && blocks.length > 0;
158
+ };
159
+
160
+ // A section ends on the last paragraph it held, so that is where its break
161
+ // goes. A section that held none gets a paragraph written for it -- and so
162
+ // does one whose last block is a table, which carries no properties for a
163
+ // break to live in. What the next instance is owed is untouched either way:
164
+ // this paragraph belongs to the section closing, not to the one opening.
165
+ let closeSection = () => {
166
+ let last = blocks.at(-1);
167
+ // oxlint-disable-next-line no-unused-expressions
168
+ (blocks.length === sectionStart || last?.raw) && blocks.push({ props: "", inner: "" });
169
+ /** @type {any} */ (blocks.at(-1)).closes = sections.at(-1);
170
+ };
171
+
172
+ /**
173
+ * The hints that move the document rather than occupy it. `reset` opens a
174
+ * section on a fresh page; a page-column count opens one that continues on
175
+ * the same page. An instance declaring both gets one section, which turns the
176
+ * page — a page already fresh does not turn twice.
177
+ *
178
+ * @type {(event: any) => void}
179
+ */
180
+ let group = (event) => {
181
+ heading = `<w:pStyle w:val="${headingAt(event.depth)}"/>`;
182
+ // A structural half-line before every instance, dropped where the document
183
+ // has not started: groups read as blocks without authored margins.
184
+ // oxlint-disable-next-line no-unused-expressions
185
+ owed.gapped ||= blocks.length > 0;
186
+ let own = counted(event.columns);
187
+ let kind = sectionFor(event.reset === "page", own);
188
+ if (!kind) return breakBefore(event);
189
+ // Its own count is what `group-end` closes; a section that merely inherits
190
+ // the report's is not one this instance opened.
191
+ // oxlint-disable-next-line no-unused-expressions
192
+ own && columned.push(event.depth);
193
+ reopen(kind);
194
+ };
195
+
196
+ /** The section an instance opens, or null where it opens none and is at most
197
+ * a page break on its own first paragraph.
198
+ * @type {(resets: boolean, own: number) => any} */
199
+ let sectionFor = (resets, own) =>
200
+ resets || own ? { reset: resets, continuous: !resets, columns: own || columns } : null;
201
+
202
+ /** Close the section this instance interrupts and open its own — unless
203
+ * nothing at all has been written, in which case this instance opens the
204
+ * document rather than ending anything, and the section it would have ended
205
+ * becomes its own.
206
+ * @type {(kind: any) => void} */
207
+ let reopen = (kind) => {
208
+ if (!blocks.length) return void (sections[sections.length - 1] = section(kind));
209
+ closeSection();
210
+ open(kind);
211
+ };
212
+
213
+ /** @type {(event: any) => void} */
214
+ let groupEnd = (event) => {
215
+ if (columned.at(-1) !== event.depth) return;
216
+ columned.pop();
217
+ reopen({ continuous: true, columns: columns });
218
+ };
219
+
220
+ /**
221
+ * The paragraph properties an item's role earns: the heading its instance is
222
+ * owed, on a group instance's *first* header item, and nothing anywhere else.
223
+ *
224
+ * @type {(role: string) => string}
225
+ */
226
+ let claimHeading = (role) => {
227
+ if (role !== "group-header") return "";
228
+ let owes = heading;
229
+ heading = "";
230
+ return owes;
231
+ };
232
+
233
+ /** One item or picture, as the content of the paragraph it becomes. `column`
234
+ * is what caps a picture: the text column, or the slot holding it.
235
+ * @type {(event: any, style: any, column?: number) => string} */
236
+ let contentOf = (event, style, column = columnWidth()) =>
237
+ event.type === "image"
238
+ ? pictureRun(event, column)
239
+ : runs(event.tokens, looking(style, ctx.intl));
240
+
241
+ /** @type {(event: any, column: number) => string} */
242
+ let pictureRun = (event, column) => {
243
+ let rId = ctx.picture(event);
244
+ return drawing(event, idOf(rId), rId, describe(event), sizeOf(event, column));
245
+ };
246
+
247
+ /** How wide the body is where it is being written: the text column, divided
248
+ * between the [page columns](../../../SCHEMA.md#page-columns) of the section
249
+ * it lands in, less the gutter between them. `708` twips is Word's own. */
250
+ let columnWidth = () => {
251
+ let count = sections.at(-1)?.columns || 1;
252
+ return (ctx.width - (GUTTER / 20) * (count - 1)) / count;
253
+ };
254
+
255
+ /** @type {(event: any) => void} */
256
+ let itemOf = (event) => {
257
+ if (split) {
258
+ let own = under(split.style, event.style);
259
+ return split.slot(own, (column) => contentOf(event, own, column));
260
+ }
261
+ let style = styleOf(event);
262
+ push(claimHeading(event.role), style, contentOf(event, style));
263
+ };
264
+
265
+ /** @type {(event: any) => void} */
266
+ let splitStart = (event) => {
267
+ split = splitting(event.slots, columnWidth(), styleOf(event));
268
+ };
269
+
270
+ let splitEnd = () => {
271
+ // oxlint-disable-next-line no-unused-expressions
272
+ split && block(split.close());
273
+ split = null;
274
+ };
275
+
276
+ /** What a table's cells are written with. `intl` is settled on the opening
277
+ * event, so it is read here rather than captured at compile.
278
+ * @type {() => { pad: Record<string, number>, intl: any }} */
279
+ let ruled = () => ({ pad: PAD, intl: ctx.intl });
280
+
281
+ /** @type {(event: any) => void} */
282
+ let tableStart = (event) => {
283
+ let widths = grid(
284
+ event.columns.map((/** @type {any} */ c) => (Number.isFinite(c.width) ? c.width : null)),
285
+ columnWidth(),
286
+ );
287
+ table = {
288
+ widths,
289
+ rows: record(event.header.cells, widths, true, under(ctx.base, event.header.style), ruled()),
290
+ };
291
+ };
292
+
293
+ /** @type {(event: any) => void} */
294
+ let row = (event) => {
295
+ // oxlint-disable-next-line no-unused-expressions
296
+ table &&
297
+ (table.rows += record(
298
+ event.cells,
299
+ table.widths,
300
+ false,
301
+ under(ctx.base, event.style),
302
+ ruled(),
303
+ ));
304
+ };
305
+
306
+ let tableEnd = () => {
307
+ // oxlint-disable-next-line no-unused-expressions
308
+ table && block(tbl(table.rows, table.widths));
309
+ table = null;
310
+ // The band gap the PDF leaves after a table, owed to whatever comes next.
311
+ owed.gapped = true;
312
+ };
313
+
314
+ return {
315
+ sections,
316
+ start,
317
+ handlers: {
318
+ "group-start": group,
319
+ "group-end": groupEnd,
320
+ item: itemOf,
321
+ image: itemOf,
322
+ "split-start": splitStart,
323
+ "split-end": splitEnd,
324
+ "table-start": tableStart,
325
+ row,
326
+ "total-row": row,
327
+ "table-end": tableEnd,
328
+ },
329
+ /** @type {(sectPr: (section: any) => string) => string} */
330
+ xml: (sectPr) =>
331
+ blocks
332
+ .map(
333
+ (b) =>
334
+ /** @type {any} */ (b).raw
335
+ ? b.inner
336
+ : paragraph(b.props + (b.closes ? sectPr(b.closes) : ""), b.inner),
337
+ )
338
+ .join("") + sectPr(sections.at(-1)),
339
+ };
340
+ };
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The page bands as Word furniture: the header and footer parts a section
3
+ * references, and the unlicensed marking that rides in every footer.
4
+ *
5
+ * A flow target has no pages of its own, so it cannot draw a band per page. It
6
+ * states each band once and lets Word repeat it, and the two things a band can
7
+ * say about the page it sits on are answered separately.
8
+ *
9
+ * *Which* page becomes a Word **field**: the engine tags a token whose
10
+ * interpolation is exactly `{{ page.number }}` or `{{ page.total }}` (see
11
+ * CONTEXT.md, "Live field"), and this module writes `PAGE` / `NUMPAGES` there
12
+ * so the reader's application recomputes the number as the document
13
+ * repaginates. Anything computed from a page value is ordinary text, frozen at
14
+ * what the probe below saw.
15
+ *
16
+ * *Where* on the page comes from the **probe**: the band closure is evaluated
17
+ * at page 1 of 2 and at page 2 of 2, and the two results are compared. Equal —
18
+ * which is what a band holding nothing but fields and literal text is — means
19
+ * one part and no `titlePg`; different means a first-page part, a default one,
20
+ * and `titlePg` in the section. The probe assumes a total of two, so a band
21
+ * gated on `page.total` sees 2, exactly as the PDF target's height probe does.
22
+ */
23
+ import { describe, drawing, idOf, sizeOf } from "./picture.js";
24
+ import { paraProps, under } from "./style.js";
25
+ import { splitting } from "./table.js";
26
+ import { looking, paragraph, run, runs } from "./text.js";
27
+ import { R, W, XML } from "./xml.js";
28
+
29
+ // The two pages every band is read at, in order: the first of two, then a
30
+ // later one. Two is the whole of what the probe assumes about the document.
31
+ const PROBE = [
32
+ { number: 1, total: 2 },
33
+ { number: 2, total: 2 },
34
+ ];
35
+
36
+ // The marking's best-effort look: a mid grey at 7 pt (half-points). Its
37
+ // presence and its place — the first paragraph of the part, its own paragraph
38
+ // — are what the spec fixes; this is not.
39
+ const MARKING_LOOK = '<w:color w:val="808080"/><w:sz w:val="14"/>';
40
+
41
+ /**
42
+ * One band's events as the blocks they become: an item or a picture is a
43
+ * paragraph, a split is a borderless one-row table, and the bracket is filled
44
+ * exactly as the body fills one — `splitting` is the shared rule, so the two
45
+ * cannot drift on what a split is.
46
+ *
47
+ * `ctx` is what a render carries that the events do not: the report default
48
+ * every band item wears under its own, the instance's `format` configuration,
49
+ * the text column a picture is capped at, and the registry a picture's bytes
50
+ * become a part in.
51
+ *
52
+ * @type {(events: any[], ctx: Band) => string}
53
+ */
54
+ let banded = (events, ctx) => {
55
+ /** @type {{ out: string, split: ReturnType<typeof splitting> | null }} */
56
+ let state = { out: "", split: null };
57
+ for (let e of events) STEPS.get(e.type)?.(state, e, ctx);
58
+ return state.out;
59
+ };
60
+
61
+ /** @type {(state: any, e: any, ctx: Band) => void} */
62
+ let open = (state, e, ctx) => {
63
+ state.split = splitting(e.slots, ctx.width, under(ctx.base, e.style));
64
+ };
65
+
66
+ /** @type {(state: any) => void} */
67
+ let close = (state) => {
68
+ state.out += state.split ? state.split.close() : "";
69
+ state.split = null;
70
+ };
71
+
72
+ /** One item or picture, into the split being filled or into the band itself.
73
+ * @type {(state: any, e: any, ctx: Band) => void} */
74
+ let draw = (state, e, ctx) => {
75
+ if (state.split) {
76
+ let own = under(state.split.style, e.style);
77
+ return state.split.slot(own, (/** @type {number} */ column) => content(e, own, column, ctx));
78
+ }
79
+ let style = under(ctx.base, e.style);
80
+ state.out += paragraph(paraProps(style), content(e, style, ctx.width, ctx));
81
+ };
82
+
83
+ // What each kind of band event does to the band being built. An event this does
84
+ // not know contributes nothing rather than failing, which is what lets a
85
+ // consumer written before an event kind existed go on working.
86
+ /** @type {Map<string, (state: any, e: any, ctx: Band) => void>} */
87
+ const STEPS = new Map([
88
+ ["split-start", open],
89
+ ["split-end", close],
90
+ ["item", draw],
91
+ ["image", draw],
92
+ ]);
93
+
94
+ /**
95
+ * One item's or picture's content.
96
+ *
97
+ * @type {(e: any, style: any, column: number, ctx: Band) => string}
98
+ */
99
+ let content = (e, style, column, ctx) => {
100
+ if (e.type !== "image") return runs(e.tokens, looking(style, ctx.intl, ctx.sequence));
101
+ let rId = ctx.picture(e);
102
+ return drawing(e, idOf(rId), rId, describe(e), sizeOf(e, column));
103
+ };
104
+
105
+ /**
106
+ * One reading of a band at one page, behind whatever the target leads the part
107
+ * with — the marking, in a footer, so a part holding only that is still a part.
108
+ *
109
+ * @type {(closure: any, at: any, lead: string, ctx: Band) => string}
110
+ */
111
+ let reading = (closure, at, lead, ctx) => lead + (closure ? banded(closure(at), ctx) : "");
112
+
113
+ /**
114
+ * What a render carries that the events do not: the report default every band
115
+ * item wears under its own, the instance's `format` configuration, the text
116
+ * column a picture is capped at, and the registry a picture's bytes become a
117
+ * part in. `body.js` holds the same shape, so the two writers describe a render
118
+ * one way.
119
+ *
120
+ * @typedef {{ base: any, intl: any, width: number,
121
+ * picture: (event: any) => string }} Ctx
122
+ */
123
+
124
+ /**
125
+ * A `Ctx` for one sequence: the same render, plus which field gives the length
126
+ * of the sequence this band's section numbers. It is the one thing a page band
127
+ * knows that the body does not.
128
+ *
129
+ * @typedef {Ctx & { sequence: string }} Band
130
+ */
131
+
132
+ /**
133
+ * The part registry: every distinct part written once, whatever asks for it.
134
+ * Sections opened by `reset: "page"` all number themselves the same way, so a
135
+ * document of two hundred invoices carries one footer part, not two hundred.
136
+ *
137
+ * @param {any} [page] `report-start.page`, absent when no band is declared.
138
+ * @param {string} [marking] The wording, on an unlicensed render.
139
+ * @param {(kind: string, target: string) => string} [relate] Registers a part
140
+ * and returns the relationship that reaches it; the caller owns the document's
141
+ * one allocator, because the body draws pictures too.
142
+ * @param {Ctx} [ctx] What a render carries that the events do not.
143
+ */
144
+ export let furnish = (page, marking, relate = () => "", ctx = EMPTY_CTX) => {
145
+ /** @type {Record<string, string>} */
146
+ let parts = {};
147
+ /** @type {Map<string, string>} */
148
+ let byXml = new Map();
149
+ /** @type {Record<string, number>} */
150
+ let counts = { header: 0, footer: 0 };
151
+
152
+ // A part for this body, or the one already written for it.
153
+ /** @type {(kind: string, body: string) => string} */
154
+ let partFor = (kind, body) => {
155
+ let tag = kind === "header" ? "hdr" : "ftr";
156
+ let xml = XML + `<w:${tag} ${W}>` + body + `</w:${tag}>`;
157
+ let seen = byXml.get(xml);
158
+ if (seen) return seen;
159
+ let target = kind + ++counts[kind] + ".xml";
160
+ let rId = relate(kind, target);
161
+ byXml.set(xml, rId);
162
+ parts["word/" + target] = xml;
163
+ return rId;
164
+ };
165
+
166
+ // One band's references. `titlePg` is one switch for both bands, and Word
167
+ // reads it as "the first page takes the first-page parts" -- so a band with
168
+ // no `first` reference shows *nothing* on page one once the other band has
169
+ // turned it on. Both therefore name a first-page part whenever either needs
170
+ // one; the steady one names the part it already wrote, which the registry
171
+ // hands back rather than writing twice.
172
+ /** @type {(kind: string, pair: string[], titlePg: boolean) => string} */
173
+ let references = (kind, [first, later], titlePg) => {
174
+ /** @type {(type: string, body: string) => string} */
175
+ let ref = (type, body) =>
176
+ `<w:${kind}Reference w:type="${type}" r:id="${partFor(kind, body)}"/>`;
177
+ return (titlePg ? ref("first", first) : "") + ref("default", later);
178
+ };
179
+
180
+ /**
181
+ * What one section's `sectPr` says about its furniture: the references, and
182
+ * whether either band read differently on the first page. A band with no
183
+ * closure and nothing for the target to lead with has no part and no
184
+ * reference — which is what a licensed report declaring no footer gets.
185
+ *
186
+ * @type {(sequence: string) => { refs: string, titlePg: boolean }}
187
+ */
188
+ let refsFor = (sequence) => {
189
+ let band = { ...ctx, sequence };
190
+ let lead = marking ? paragraph("", run(marking, MARKING_LOOK)) : "";
191
+ let bands = [
192
+ { kind: "header", closure: page?.header, lead: "" },
193
+ { kind: "footer", closure: page?.footer, lead },
194
+ ]
195
+ .filter((band) => band.closure || band.lead)
196
+ .map(({ kind, closure, lead: own }) => ({
197
+ kind,
198
+ pair: PROBE.map((at) => reading(closure, at, own, band)),
199
+ }));
200
+ let titlePg = bands.some(({ pair }) => pair[0] !== pair[1]);
201
+ return {
202
+ refs: bands.map(({ kind, pair }) => references(kind, pair, titlePg)).join(""),
203
+ titlePg,
204
+ };
205
+ };
206
+
207
+ return { parts, refsFor };
208
+ };
209
+
210
+ /** A render that declares no bands and marks nothing needs none of it. */
211
+ export const EMPTY_CTX = { base: null, intl: null, width: 0, picture: () => "" };
212
+
213
+ /** The namespaces a document referencing furniture declares. */
214
+ export const DOCUMENT_NS = W + " " + R;
package/lib/index.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { Target } from "quario";
2
+
3
+ /**
4
+ * Page geometry, in PostScript points: the host's own, its margin one a
5
+ * document may declare instead. Restated rather than taken from
6
+ * `@quario/layout` — a flow target does not run the layout — and hand-synced
7
+ * with it under `test/page-sizes.test.js`.
8
+ */
9
+ export interface DocxPage {
10
+ size?: "A4" | "letter" | [number, number];
11
+ margin?: number;
12
+ }
13
+
14
+ /** Optional document properties; never includes dates, so output stays deterministic. */
15
+ export interface DocxMeta {
16
+ title?: string;
17
+ author?: string;
18
+ subject?: string;
19
+ }
20
+
21
+ /** Host controls, taken and validated at the factory call. */
22
+ export interface DocxOptions {
23
+ page?: DocxPage;
24
+ meta?: DocxMeta;
25
+ }
26
+
27
+ /**
28
+ * The Word render target:
29
+ * `quario().report(schema).render(docx({ page }), data)` resolves the document
30
+ * bytes described in SCHEMA.md ("The DOCX target").
31
+ */
32
+ export function docx(options?: DocxOptions): Target<"docx", Promise<Uint8Array>>;