@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/CHANGELOG.md +32 -0
- package/LICENSE +219 -0
- package/README.md +100 -0
- package/lib/body.js +340 -0
- package/lib/furniture.js +214 -0
- package/lib/index.d.ts +32 -0
- package/lib/index.js +289 -0
- package/lib/pack.js +35 -0
- package/lib/page.js +111 -0
- package/lib/picture.js +91 -0
- package/lib/style.js +270 -0
- package/lib/stylepart.js +72 -0
- package/lib/table.js +180 -0
- package/lib/text.js +141 -0
- package/lib/xml.js +30 -0
- package/package.json +64 -0
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
|
+
};
|
package/lib/furniture.js
ADDED
|
@@ -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>>;
|