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