@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/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));
|