@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/index.js ADDED
@@ -0,0 +1,289 @@
1
+ /**
2
+ * @quario/docx — the Word render target: a pure consumer of the engine's
3
+ * public event stream, resolving the bytes of a `.docx` package. A **flow**
4
+ * target, not a painter: it states the geometry and the typography the preview
5
+ * shows and lets Word paginate, so a page break is Word's and everything above
6
+ * one is quario's.
7
+ *
8
+ * This module is the entry — the options a host writes, the walk over the
9
+ * stream, and the parts a package is made of. The writers each event kind
10
+ * needs live beside it: `body.js` for the paragraphs a report resolves to,
11
+ * `furniture.js` for the parts a page band becomes, `styles.js` for the base
12
+ * every part inherits, and `text.js` for the runs all of them are made of.
13
+ */
14
+ import { walk } from "quario";
15
+ import { bodyOf } from "./body.js";
16
+ import { DOCUMENT_NS, furnish } from "./furniture.js";
17
+ import { STYLES } from "./stylepart.js";
18
+ import { DOCUMENT_PAGES, SECTION_PAGES } from "./text.js";
19
+ import { geometry, pageBox, twips } from "./page.js";
20
+ import { pack } from "./pack.js";
21
+ import { W, XML, esc } from "./xml.js";
22
+
23
+ const RELS = 'xmlns="http://schemas.openxmlformats.org/package/2006/relationships"';
24
+ const OFFICE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
25
+ const PACKAGE = "http://schemas.openxmlformats.org/package/2006/relationships";
26
+ const WML = "application/vnd.openxmlformats-officedocument.wordprocessingml";
27
+
28
+ // The distance from the paper's edge to a header or a footer, in twips: Word's
29
+ // own default, and what the page bands will be laid against.
30
+ const FURNITURE = 720;
31
+
32
+ /** @type {(msg: string) => never} */
33
+ let err = (msg) => {
34
+ throw Error(msg);
35
+ };
36
+
37
+ // Split from `object` below rather than inlined: the three terms together
38
+ // breach the complexity budget `npm run fallow` holds this package to.
39
+ /** @type {(value: any) => boolean} */
40
+ let plain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
41
+
42
+ /** @type {(value: any, at: string) => any} */
43
+ let object = (value, at) => {
44
+ // oxlint-disable-next-line no-unused-expressions
45
+ value === undefined || plain(value) || err(at + ": expected an object");
46
+ return value;
47
+ };
48
+
49
+ /** @type {(value: any, keys: string[], at: string) => void} */
50
+ let only = (value, keys, at) => {
51
+ for (let key in value) if (!keys.includes(key)) err(at + ': unknown option "' + key + '"');
52
+ };
53
+
54
+ // The three document properties a host may write.
55
+ let PROPERTIES = ["title", "author", "subject"];
56
+
57
+ // The document properties, validated: strings, and never a date. What a host
58
+ // cannot write is what keeps a render deterministic -- `core.xml` is the one
59
+ // part with a slot for a clock, and it has none of them.
60
+ /** @type {(meta: any) => any} */
61
+ let metaOf = (meta) => {
62
+ object(meta, "options.meta");
63
+ only(meta, PROPERTIES, "options.meta");
64
+ for (let key in meta)
65
+ // oxlint-disable-next-line no-unused-expressions
66
+ typeof meta[key] === "string" || err("options.meta." + key + ": expected a string");
67
+ return meta;
68
+ };
69
+
70
+ /**
71
+ * One section. The child order is the schema's and Word is strict about it:
72
+ * the part references, then `type`, the geometry, `pgNumType`, `cols`, and
73
+ * `titlePg` last.
74
+ *
75
+ * A section that restarts the numbering says so twice — `nextPage` so it opens
76
+ * on a fresh page, which is the "each instance starts on a fresh page" half of
77
+ * `reset`, and `pgNumType` for the numbering half. The first section says
78
+ * neither: it is where the document already was.
79
+ *
80
+ * @type {(geo: { width: number, height: number, margin: number }, section: Section) => string}
81
+ */
82
+ let sectPr = (geo, section) => {
83
+ let m = twips(geo.margin);
84
+ return (
85
+ "<w:sectPr>" +
86
+ section.refs +
87
+ breakType(section) +
88
+ `<w:pgSz w:w="${twips(geo.width)}" w:h="${twips(geo.height)}"/>` +
89
+ `<w:pgMar w:top="${m}" w:right="${m}" w:bottom="${m}" w:left="${m}"` +
90
+ ` w:header="${FURNITURE}" w:footer="${FURNITURE}" w:gutter="0"/>` +
91
+ (section.reset ? '<w:pgNumType w:start="1"/>' : "") +
92
+ `<w:cols w:num="${section.columns || 1}" w:space="708" w:equalWidth="1"/>` +
93
+ (section.titlePg ? "<w:titlePg/>" : "") +
94
+ "</w:sectPr>"
95
+ );
96
+ };
97
+
98
+ /** How a section starts relative to the one before it: on a fresh page, where
99
+ * it restarts the numbering, and on the same one where it only re-columns.
100
+ * @type {(section: Section) => string} */
101
+ let breakType = ({ reset, continuous }) =>
102
+ reset ? '<w:type w:val="nextPage"/>' : continuous ? '<w:type w:val="continuous"/>' : "";
103
+
104
+ /** @type {(meta: any) => string} */
105
+ let core = (meta) =>
106
+ XML +
107
+ '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"' +
108
+ ' xmlns:dc="http://purl.org/dc/elements/1.1/">' +
109
+ (meta.title ? `<dc:title>${esc(meta.title)}</dc:title>` : "") +
110
+ (meta.author ? `<dc:creator>${esc(meta.author)}</dc:creator>` : "") +
111
+ (meta.subject ? `<dc:subject>${esc(meta.subject)}</dc:subject>` : "") +
112
+ "</cp:coreProperties>";
113
+
114
+ /** @typedef {{ reset?: boolean, continuous?: boolean, columns?: number,
115
+ * refs: string, titlePg: boolean }} Section */
116
+
117
+ // The parts every document relates to, in relationship order. A furniture part
118
+ // numbers from after them, and both the content types and the relationships
119
+ // below are one map over the pair, so a part cannot reach the package through
120
+ // one and not the other.
121
+ // A picture's bytes are typed by extension rather than by part name, the way
122
+ // `rels` and `xml` already are: one `Default` covers every image of that kind.
123
+ const IMAGE_TYPES = [
124
+ ["png", "png"],
125
+ ["jpeg", "jpeg"],
126
+ ];
127
+
128
+ const FIXED = [
129
+ { path: "word/settings.xml", target: "settings.xml", kind: "settings", rId: "rId1" },
130
+ { path: "word/styles.xml", target: "styles.xml", kind: "styles", rId: "rId2" },
131
+ ];
132
+
133
+ // The content type of one furniture part, and the relationship that reaches
134
+ // it. Both are keyed off the same `{ path, kind, rId }` the registry hands
135
+ // back, so a part cannot reach the package through one and not the other.
136
+ /** @type {(part: { path: string, kind: string }) => string} */
137
+ let override = ({ path, kind }) =>
138
+ `<Override PartName="/${path}" ContentType="${WML}.${kind}+xml"/>`;
139
+ /** @type {(part: { target: string, kind: string, rId: string }) => string} */
140
+ let related = ({ target, kind, rId }) =>
141
+ `<Relationship Id="${rId}" Type="${OFFICE}/${kind}" Target="${target}"/>`;
142
+
143
+ /**
144
+ * The parts of the package. The floor is what Word opens: the content types,
145
+ * the package relationships and `document.xml` — plus `settings.xml`, which is
146
+ * not required at all and is here because Word opens a document without one in
147
+ * Compatibility Mode. `docProps/core.xml` rides only when a host wrote `meta`,
148
+ * and the header and footer parts only when a band or the marking needs one.
149
+ *
150
+ * @type {(body: string, meta: any, furniture: any, rels: any[]) => Record<string, string>}
151
+ */
152
+ let parts = (body, meta, furniture, rels) => ({
153
+ "[Content_Types].xml":
154
+ XML +
155
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
156
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
157
+ '<Default Extension="xml" ContentType="application/xml"/>' +
158
+ `<Override PartName="/word/document.xml" ContentType="${WML}.document.main+xml"/>` +
159
+ rels.map(override).join("") +
160
+ IMAGE_TYPES.filter(([ext]) => rels.some((r) => r.target.endsWith("." + ext)))
161
+ .map(([ext, type]) => `<Default Extension="${ext}" ContentType="image/${type}"/>`)
162
+ .join("") +
163
+ (meta
164
+ ? '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
165
+ : "") +
166
+ "</Types>",
167
+ "_rels/.rels":
168
+ XML +
169
+ `<Relationships ${RELS}>` +
170
+ `<Relationship Id="rId1" Type="${OFFICE}/officeDocument" Target="word/document.xml"/>` +
171
+ (meta
172
+ ? `<Relationship Id="rId2" Type="${PACKAGE}/metadata/core-properties" Target="docProps/core.xml"/>`
173
+ : "") +
174
+ "</Relationships>",
175
+ "word/document.xml": XML + `<w:document ${DOCUMENT_NS}><w:body>${body}</w:body></w:document>`,
176
+ "word/_rels/document.xml.rels":
177
+ XML + `<Relationships ${RELS}>` + rels.map(related).join("") + "</Relationships>",
178
+ "word/settings.xml":
179
+ XML +
180
+ `<w:settings ${W}><w:compat>` +
181
+ '<w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/>' +
182
+ "</w:compat></w:settings>",
183
+ "word/styles.xml": STYLES,
184
+ ...furniture.parts,
185
+ ...(meta ? { "docProps/core.xml": core(meta) } : {}),
186
+ });
187
+
188
+ /**
189
+ * The Word render target:
190
+ * `quario().report(schema).render(docx({ page }), data)` resolves the document
191
+ * bytes described in SCHEMA.md ("The DOCX target"). Options are taken and
192
+ * validated at the factory call.
193
+ *
194
+ * @param {any} [options] Host controls: `page` and `meta`.
195
+ * @returns {{name: "docx", compile: (stream: any) => (data?: any) => Promise<Uint8Array>}}
196
+ * The target (see SCHEMA.md, "Instances and targets").
197
+ */
198
+ export function docx(options) {
199
+ object(options, "options");
200
+ only(options, ["page", "meta"], "options");
201
+ let page = object(options?.page, "options.page");
202
+ let meta = metaOf(options?.meta);
203
+ // The size fails here rather than at the first render: a host wrote it, so
204
+ // a host hears about it where it was written. The margin is validated again
205
+ // per render, because the document may be the one declaring it.
206
+ pageBox(page, "options.page");
207
+ /** @type {(stream: any) => (data?: any) => Promise<Uint8Array>} */
208
+ let compile = (stream) => async (data) => {
209
+ // The sections are written after the walk, so the opening event is taken
210
+ // in its handler rather than peeked ahead of the driver: the margin a
211
+ // document may declare instead of the host is settled by the time any
212
+ // second event is pulled, and the stream reaches `walk` unwrapped. The
213
+ // walk is what spends the render's budget and breathes for the host
214
+ // meanwhile.
215
+ let geo = geometry(page);
216
+ // Every relationship the document carries, in one place: the parts it
217
+ // always has, then whatever the furniture and the body ask for. One
218
+ // allocator, because a page band and the body both draw pictures and two
219
+ // would hand out the same number twice.
220
+ /** @type {{ path: string, target: string, kind: string, rId: string }[]} */
221
+ let rels = [...FIXED];
222
+ /** @type {Record<string, Uint8Array>} */
223
+ let media = {};
224
+ /** @type {(kind: string, target: string) => string} */
225
+ let relate = (kind, target) => {
226
+ let rId = "rId" + (rels.length + 1);
227
+ rels.push({ path: "word/" + target, target, kind, rId });
228
+ return rId;
229
+ };
230
+ // One part per distinct picture. Keyed on the bytes themselves: the
231
+ // page-band probe reads each band twice and every section reads it again,
232
+ // and a logo that arrived by reading a field is the same array each time,
233
+ // so the part -- and the header XML naming it -- stays identical and the
234
+ // furniture's own dedup still collapses the sections. A `source` that
235
+ // *computes* fresh bytes per call writes a part per call, which is
236
+ // wasteful and still correct.
237
+ /** @type {Map<Uint8Array, string>} */
238
+ let drawn = new Map();
239
+ /** @type {(event: any) => string} */
240
+ let picture = (event) => {
241
+ let seen = drawn.get(event.bytes);
242
+ if (seen) return seen;
243
+ let target = "media/image" + (drawn.size + 1) + "." + event.format;
244
+ media["word/" + target] = event.bytes;
245
+ let rId = relate("image", target);
246
+ drawn.set(event.bytes, rId);
247
+ return rId;
248
+ };
249
+ let furniture = furnish();
250
+
251
+ /** @type {(kind: any) => Section} */
252
+ let section = (kind) => ({
253
+ ...kind,
254
+ ...furniture.refsFor(kind.reset ? SECTION_PAGES : DOCUMENT_PAGES),
255
+ });
256
+ let body = bodyOf(section);
257
+ await walk(stream(data), {
258
+ ...body.handlers,
259
+ "report-start": (event) => {
260
+ geo = geometry(page, event);
261
+ // What a render carries that the events do not, settled once and handed
262
+ // to both writers: a fifth instance field must be read here and nowhere
263
+ // else (`furniture.js`, `Ctx`).
264
+ let ctx = {
265
+ base: event.style || null,
266
+ intl: { locale: event.locale, currency: event.currency, timeZone: event.timeZone },
267
+ width: geo.width - 2 * geo.margin,
268
+ picture,
269
+ };
270
+ furniture = furnish(event.page, event.marking, relate, ctx);
271
+ body.start(ctx, event);
272
+ },
273
+ });
274
+ // A stream that opened no section at all -- one walked from part-way
275
+ // through, which the driver allows -- still describes a page.
276
+ // oxlint-disable-next-line no-unused-expressions
277
+ body.sections.length || body.sections.push(section({}));
278
+ return pack({
279
+ ...parts(
280
+ body.xml((s) => sectPr(geo, s)),
281
+ meta,
282
+ furniture,
283
+ rels,
284
+ ),
285
+ ...media,
286
+ });
287
+ };
288
+ return { name: "docx", compile };
289
+ }
package/lib/pack.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The package: named parts in, one zip out. The **only** module that imports
3
+ * fflate, which is what makes the writer a seam — a later one (a sibling OOXML
4
+ * writer, `CompressionStream`) replaces this file and nothing else.
5
+ *
6
+ * Only the synchronous names are ever called. The asynchronous family would
7
+ * reach for a worker built from a string, and the suites run under
8
+ * `--disallow-code-generation-from-strings` with the browser page under a
9
+ * `default-src 'none'` policy, so the path this target takes is proved rather
10
+ * than promised.
11
+ */
12
+ import { strToU8, zipSync } from "fflate";
13
+
14
+ // Every entry's modification time, pinned. A zip stamps each local header with
15
+ // the clock, so without this two renders of one report differ in bytes and no
16
+ // digest could pin anything. fflate builds the DOS stamp from the local-time
17
+ // getters, so this is a local 1980-01-01 rather than an epoch instant.
18
+ let EPOCH = new Date(1980, 0, 1);
19
+
20
+ /**
21
+ * Pack the parts of an OOXML package.
22
+ *
23
+ * @param {Record<string, string | Uint8Array>} parts Part paths to their
24
+ * contents: XML as a string, a picture's bytes as they arrived.
25
+ * @returns {Uint8Array} The zip, byte-identical for byte-identical parts.
26
+ */
27
+ export let pack = (parts) => {
28
+ /** @type {Record<string, Uint8Array>} */
29
+ let entries = {};
30
+ for (let path in parts) {
31
+ let part = parts[path];
32
+ entries[path] = typeof part === "string" ? strToU8(part) : part;
33
+ }
34
+ return zipSync(entries, { level: 6, mtime: EPOCH });
35
+ };
package/lib/page.js ADDED
@@ -0,0 +1,111 @@
1
+ // fallow-ignore-file duplicate-export -- this module is the sanctioned copy of `packages/layout/lib/page.js` and keeps its names on purpose: `test/page-sizes.test.js` imports both and compares what each answers, which only works while the two spell the page the same way (ADR 0039).
2
+ /**
3
+ * The page box: what a host's `page` option means in points, and the twips a
4
+ * section states it in. A **deliberate copy** of `packages/layout/lib/page.js`
5
+ * — the rules and their wording, not the frame derivation this target has no
6
+ * use for, because Word does the paginating.
7
+ *
8
+ * ADR 0039 gates the page-size table to one home, and `test/page-sizes.test.js`
9
+ * is that gate: a second table is a size that means one thing here and another
10
+ * there. This target is the one consumer that cannot reach the layout's — a
11
+ * flow target measures nothing, and taking `@quario/layout` would install the
12
+ * pagination engine and its font metrics for a two-entry table (ADR 0014: a
13
+ * target imports only public engine helpers). So the copy is hand-synced under
14
+ * that same gate, which asserts this table and these wordings against the
15
+ * layout's: change one and change the other, or the gate fails.
16
+ */
17
+
18
+ // The named sizes, in points, exactly as `@quario/layout` states them; the
19
+ // conversion to twips happens at use, so the two tables stay comparable.
20
+ // fallow-ignore-next-line code-duplication -- @quario/docx keeps a deliberate copy of these rules: a flow target runs no layout, and taking @quario/layout for a two-entry table would install the pagination engine with it. test/page-sizes.test.js holds the two in sync (ADR 0039).
21
+ let SIZES = /** @type {Record<string, [number, number]>} */ ({
22
+ A4: [595.28, 841.89],
23
+ letter: [612, 792],
24
+ });
25
+
26
+ /** @type {(msg: string) => never} */
27
+ let err = (msg) => {
28
+ throw Error(msg);
29
+ };
30
+
31
+ /** @type {(value: number) => boolean} */
32
+ let positive = (value) => Number.isFinite(value) && value > 0;
33
+
34
+ /** @type {(size: any) => any} */
35
+ let known = (size) => (Object.hasOwn(SIZES, size) ? SIZES[size] : null);
36
+
37
+ /** @type {(size: any, at: string) => any} */
38
+ let named = (size = "A4", at) => {
39
+ let dimensions = Array.isArray(size) ? size : known(size);
40
+ // oxlint-disable-next-line no-unused-expressions
41
+ dimensions || err(at + '.size: unknown page size "' + size + '"');
42
+ return dimensions;
43
+ };
44
+
45
+ /** @type {(dimensions: any, at: string) => { width: number, height: number }} */
46
+ let pair = (dimensions, at) => {
47
+ let width = +dimensions[0],
48
+ height = +dimensions[1];
49
+ // oxlint-disable-next-line no-unused-expressions
50
+ (positive(width) && positive(height)) || err(at + ".size: expected finite positive dimensions");
51
+ return { width, height };
52
+ };
53
+
54
+ /** @type {(margin: any, width: number, height: number, path: string) => number} */
55
+ let marginOf = (margin = 54, width, height, path) => {
56
+ let fits = Number.isFinite(margin) && margin >= 0 && 2 * margin < Math.min(width, height);
57
+ // oxlint-disable-next-line no-unused-expressions
58
+ fits || err(path + ": expected a non-negative number smaller than half the page");
59
+ return margin;
60
+ };
61
+
62
+ // Size is settled at the factory; margin may come from the document's
63
+ // `report-start`, so a render peeks that event before writing a section.
64
+ /** @type {(page: any) => any} */
65
+ let hostMargin = (page) => (Object.hasOwn(page, "margin") ? page.margin : undefined);
66
+ /** @type {(opening: any) => any} */
67
+ let docMargin = (opening) => opening?.margin;
68
+ /** @type {(doc: any, host: any) => boolean} */
69
+ let bothMargins = (doc, host) => doc != null && host !== undefined;
70
+ /** @type {(opening: any, page: any, at: string) => { value: any, path: string }} */
71
+ let marginChoice = (opening, page, at) => {
72
+ let host = hostMargin(page);
73
+ let doc = docMargin(opening);
74
+ if (bothMargins(doc, host)) err("page.margin: document and target both declare a margin");
75
+ return { value: doc ?? host, path: doc != null ? "page.margin" : at + ".margin" };
76
+ };
77
+
78
+ /**
79
+ * The page box a `page` option describes, validated: width, height and margin
80
+ * in points. `at` prefixes the failure, so a host hears where it wrote the
81
+ * mistake.
82
+ *
83
+ * @param {any} page The `page` option, or undefined for the defaults.
84
+ * @param {string} [at] The option's name in a failure.
85
+ * @returns {{ width: number, height: number, margin: number }}
86
+ */
87
+ export let pageBox = (page, at = "page") => {
88
+ let { width, height } = pair(named(page?.size, at), at);
89
+ return { width, height, margin: marginOf(page?.margin, width, height, at + ".margin") };
90
+ };
91
+
92
+ /**
93
+ * The page box for a render: the host's size, and whichever margin the host
94
+ * and the opening event agreed on.
95
+ *
96
+ * @type {(page: any, opening?: any) => { width: number, height: number, margin: number }}
97
+ */
98
+ export let geometry = (page = {}, opening) => {
99
+ let { width, height } = pageBox(page, "options.page");
100
+ let chosen = marginChoice(opening, page, "options.page");
101
+ return { width, height, margin: marginOf(chosen.value, width, height, chosen.path) };
102
+ };
103
+
104
+ /**
105
+ * Points as twips, the unit WordprocessingML states a page in. Rounded, not
106
+ * truncated: A4's 595.28 x 841.89 pt lands on 11906 x 16838, the twips Word
107
+ * itself writes, where truncation would be a twip short of both.
108
+ *
109
+ * @type {(points: number) => number}
110
+ */
111
+ export let twips = (points) => Math.round(points * 20);
package/lib/picture.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * An [image item](../../../SCHEMA.md#image-item) as an inline picture. Never
3
+ * floating: a flow target states the document and lets Word lay it out, and a
4
+ * floating anchor is a position this target has no page to position against.
5
+ *
6
+ * The size is the picture's own, read by the engine out of the file header, at
7
+ * 96 dpi and capped at the text width — `fit: "width"` scales it to that width
8
+ * instead. Word measures a drawing in EMUs, 914400 to the inch, so a pixel is
9
+ * 9525 of them.
10
+ */
11
+ import { text } from "quario";
12
+ import { esc } from "./xml.js";
13
+
14
+ const DRAWING = "http://schemas.openxmlformats.org/drawingml/2006";
15
+ const WP = `xmlns:wp="${DRAWING}/wordprocessingDrawing"`;
16
+ const A = `xmlns:a="${DRAWING}/main"`;
17
+ const PIC = `xmlns:pic="${DRAWING}/picture"`;
18
+ const REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
19
+
20
+ /** EMUs per pixel at 96 dpi, and per point. */
21
+ const PER_PIXEL = 9525;
22
+ const PER_POINT = 12700;
23
+
24
+ /**
25
+ * The size one picture is drawn at, in EMUs: its own, capped at the text width,
26
+ * or scaled to that width where the author declared `fit: "width"`. The aspect
27
+ * ratio is kept either way, so a cap is a scale and never a crop.
28
+ *
29
+ * @type {(event: any, width: number) => { cx: number, cy: number }}
30
+ */
31
+ export let sizeOf = (event, width) => {
32
+ let limit = width * PER_POINT;
33
+ let natural = event.width * PER_PIXEL;
34
+ let scale = event.fit === "width" ? limit / natural : Math.min(1, limit / natural);
35
+ return {
36
+ cx: Math.max(1, Math.round(natural * scale)),
37
+ cy: Math.max(1, Math.round(event.height * PER_PIXEL * scale)),
38
+ };
39
+ };
40
+
41
+ /**
42
+ * The drawing. `id` numbers the picture within the document, `rId` names the
43
+ * part its bytes live in, and `descr` carries the rendered `alt` — this is the
44
+ * first target after HTML to write one, so a reader's own accessibility tools
45
+ * see what the author wrote.
46
+ *
47
+ * @type {(event: any, id: number, rId: string, alt: string, size: { cx: number, cy: number }) => string}
48
+ */
49
+ export let drawing = (event, id, rId, alt, size) => {
50
+ let extent = `<wp:extent cx="${size.cx}" cy="${size.cy}"/>`;
51
+ // Word's own wording for a drawing with no name of its own. Deliberately not
52
+ // the media file's name: the id numbers the drawing, and a picture reused in
53
+ // two places is one part under two drawings.
54
+ let name = "Picture " + id;
55
+ return (
56
+ `<w:r><w:drawing><wp:inline ${WP} ${A} ${PIC} distT="0" distB="0" distL="0" distR="0">` +
57
+ extent +
58
+ `<wp:docPr id="${id}" name="${esc(name)}" descr="${esc(alt)}"/>` +
59
+ "<a:graphic><a:graphicData " +
60
+ `uri="${DRAWING}/picture">` +
61
+ "<pic:pic><pic:nvPicPr>" +
62
+ `<pic:cNvPr id="${id}" name="${esc(name)}" descr="${esc(alt)}"/>` +
63
+ "<pic:cNvPicPr/></pic:nvPicPr>" +
64
+ `<pic:blipFill><a:blip xmlns:r="${REL}" r:embed="${rId}"/>` +
65
+ "<a:stretch><a:fillRect/></a:stretch></pic:blipFill>" +
66
+ '<pic:spPr><a:xfrm><a:off x="0" y="0"/>' +
67
+ `<a:ext cx="${size.cx}" cy="${size.cy}"/></a:xfrm>` +
68
+ '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>' +
69
+ "</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"
70
+ );
71
+ };
72
+
73
+ /**
74
+ * A picture's description, joined through the engine's own display rule — so a
75
+ * `Date` in an `alt` reads as ISO 8601 UTC here as it does everywhere else, and
76
+ * the bytes do not move with the host's zone.
77
+ *
78
+ * @type {(event: any) => string}
79
+ */
80
+ export let describe = (event) => text(event.alt || []);
81
+
82
+ /**
83
+ * The number that names a drawing: its relationship's. One id space for the
84
+ * whole document, which is what `docPr` wants — a counter per writer hands the
85
+ * same number to a page band's picture and the body's — and stable across the
86
+ * two readings of a band, where a running count would move and make every band
87
+ * holding a logo look like one that differs on the first page.
88
+ *
89
+ * @type {(rId: string) => number}
90
+ */
91
+ export let idOf = (rId) => Number(rId.slice(3));