@quario/docx 0.2.0 → 0.3.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # @quario/docx
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **A `capabilities` descriptor.** The target exports what it makes of each declaration the spec's support matrix names — `resolved`, `approximated`, `withdrawn` or `unread` — beside its factory, so a host can ask before it renders rather than reading the prose. It is keyed by the schema name an author writes (`valign`, `break`, `page.margin`, and `column.width` / `slot.width` where one word carries two declarations), hand-written and gated against that matrix. It carries no version of its own: the package that ships it is the version.
8
+ - **A run that carries an `href` is a Word hyperlink.** A URL becomes a relationship of the part the run lands in, marked as leaving the package, and one relationship serves every run pointing at the same place. An `href` beginning `#` becomes a `w:anchor`, and the first instance of the group carrying that `label` writes the bookmark it names, beside the heading that item already takes. A run whose text is empty writes no link and relates to nothing. The support matrix recorded this target as withdrawing `href`, and it now resolves it.
9
+
10
+ Two things a page band's own part had wrong are fixed with it. A header or footer now carries its **own relationships part**, so a picture or a link inside a band resolves in the part that refers to it rather than only in `document.xml`'s. And `[Content_Types].xml` no longer writes an `Override` for a relationship that names no WordprocessingML part. That entry typed a picture's bytes as a document part, which said nothing true about them.
11
+
12
+ - **`negative` and `zero` reach the runs.** The target presents through the engine's `format()`, so a cell declaring either reads the same in Word as on the page.
13
+ - **A collapsed group does not draw its content.** An instance whose group declares `collapsed` renders as its header and footer, with the rows between them left out — a page has nowhere to put a row a reader could open. Aggregates are unchanged, so a footer still totals them.
14
+ - **The report header scales with the report default.** A report-header run is written at 1.4× the report default's size rather than a fixed 14, rounded to Word's half-points. A document declaring no size keeps its bytes, because 1.4× the base 10 is the same 14. A document declaring `"size": 20` now renders its report header at 28 where it rendered at 14, smaller than its own body.
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies
19
+ - quario@0.11.0
20
+
3
21
  ## 0.2.0
4
22
 
5
23
  ### Minor Changes
package/lib/body.js CHANGED
@@ -34,12 +34,11 @@
34
34
  * written per run), the report default, the band-role default, then the node's
35
35
  * own. A `false` in an inner layer is a declaration and wins.
36
36
  */
37
- import { headingAt } from "./stylepart.js";
37
+ import { BASE, headingAt } from "./stylepart.js";
38
38
  import { paraProps, under } from "./style.js";
39
39
  import { grid, record, tbl } from "./table.js";
40
40
  import { content, splitBlock } from "./content.js";
41
- import { EMPTY_CTX } from "./furniture.js";
42
- import { paragraph } from "./text.js";
41
+ import { bookmark, paragraph } from "./text.js";
43
42
 
44
43
  // The page break a paragraph wears, spelled once: `push` folds it into a
45
44
  // paragraph's own properties, and `carrier` gives it one where no paragraph is
@@ -52,10 +51,21 @@ const BREAK = "<w:pageBreakBefore/>";
52
51
  // agreement; it is not shared code, and that is the ADR's point.
53
52
  /** @type {Record<string, any>} */
54
53
  const ROLES = {
55
- "report-header": { bold: true, size: 14 },
54
+ "report-header": { bold: true, scale: 1.4 },
56
55
  "group-header": { bold: true },
57
56
  };
58
57
 
58
+ // A role's default as a style block: `scale` is a factor over the report
59
+ // default's size, or over the base the style part writes when the report
60
+ // declared none, so a larger document keeps its headline larger.
61
+ /** @type {(role: string, base: any) => any} */
62
+ const roleStyle = (role, base) => {
63
+ let { scale, ...rest } = ROLES[role];
64
+ // The engine admits only a positive number, so absent is the one other case.
65
+ let size = base?.size || BASE;
66
+ return scale ? { ...rest, size: Math.round(scale * size * 100) / 100 } : rest;
67
+ };
68
+
59
69
  // The cell padding this target supplies on a side the author did not name: 6 pt
60
70
  // across, 2 pt down, the layout's own numbers. A named `0` beats it, which is
61
71
  // what makes the declaration a declaration (`docs/adr/0040`).
@@ -91,16 +101,26 @@ export let bodyOf = (section) => {
91
101
  // paragraph carries one `spacing` element and the author may have declared
92
102
  // it -- so the gap layers under their own `spaceBefore`, which then wins.
93
103
  let owed = { broken: false, gapped: false };
94
- // The heading a group instance's *first* header item is still owed, set when
95
- // the instance opens and taken by whichever item claims it. One string rather
96
- // than a depth and a flag: the two only ever moved together.
97
- let heading = "";
104
+ // What a group instance's *first* header item is still owed, set when the
105
+ // instance opens and taken by whichever item claims it: the heading its depth
106
+ // earns, and the bookmark its `label` is the destination of. One record rather
107
+ // than a depth, a flag and a name: they only ever move together.
108
+ /** @type {{ props: string, mark: { id: number, name: string } | null }} */
109
+ let owes = { props: "", mark: null };
110
+ // The labels a destination has already been written for, and how many there
111
+ // are. A label two instances share is one destination and the first wins
112
+ // (SCHEMA.md, "Style declarations"), and Word's bookmark id is an integer the
113
+ // document uses once.
114
+ /** @type {Set<string>} */
115
+ let labelled = new Set();
98
116
  /** What a render carries that the events do not: the report default, the
99
117
  * instance's `format` configuration, the text column and the picture
100
118
  * registry. Settled once, on the opening event, and read-only after —
101
119
  * `content.js`'s `Ctx`, so the two writers describe a render one way.
120
+ * Nothing reads it before then, which is why the picture registry the real
121
+ * one carries is absent here rather than stubbed.
102
122
  * @type {import("./content.js").Ctx} */
103
- let ctx = EMPTY_CTX;
123
+ let ctx = /** @type {any} */ ({ base: null, intl: null, width: 0 });
104
124
  /** The table being filled, if any: its grid, and the rows written so far. */
105
125
  /** @type {{ widths: number[], rows: string } | null} */
106
126
  let table = null;
@@ -160,7 +180,7 @@ export let bodyOf = (section) => {
160
180
  /** @type {(event: any) => any} */
161
181
  let styleOf = (event) =>
162
182
  under(
163
- under(ctx.base, Object.hasOwn(ROLES, event.role) ? ROLES[event.role] : null),
183
+ under(ctx.base, Object.hasOwn(ROLES, event.role) ? roleStyle(event.role, ctx.base) : null),
164
184
  event.style,
165
185
  );
166
186
 
@@ -191,7 +211,7 @@ export let bodyOf = (section) => {
191
211
  * @type {(event: any) => void}
192
212
  */
193
213
  let group = (event) => {
194
- heading = `<w:pStyle w:val="${headingAt(event.depth)}"/>`;
214
+ owes = { props: `<w:pStyle w:val="${headingAt(event.depth)}"/>`, mark: marking(event.label) };
195
215
  // A structural half-line before every instance, dropped where the document
196
216
  // has not started: groups read as blocks without authored margins.
197
217
  // oxlint-disable-next-line no-unused-expressions
@@ -237,17 +257,27 @@ export let bodyOf = (section) => {
237
257
  if (frame) closeInstance(frame);
238
258
  };
239
259
 
260
+ /** The destination an instance's label is, or none where it declared none or
261
+ * an earlier instance already carries it.
262
+ * @type {(label: any) => { id: number, name: string } | null} */
263
+ let marking = (label) => {
264
+ if (!label || labelled.has(label)) return null;
265
+ labelled.add(label);
266
+ return { id: labelled.size, name: label };
267
+ };
268
+
240
269
  /**
241
- * The paragraph properties an item's role earns: the heading its instance is
242
- * owed, on a group instance's *first* header item, and nothing anywhere else.
270
+ * What an item's role earns from its instance: the heading and the bookmark
271
+ * it is owed, on a group instance's *first* header item, and nothing anywhere
272
+ * else.
243
273
  *
244
- * @type {(role: string) => string}
274
+ * @type {(role: string) => { props: string, mark: { id: number, name: string } | null }}
245
275
  */
246
- let claimHeading = (role) => {
247
- if (role !== "group-header") return "";
248
- let owes = heading;
249
- heading = "";
250
- return owes;
276
+ let claim = (role) => {
277
+ if (role !== "group-header") return { props: "", mark: null };
278
+ let owed = owes;
279
+ owes = { props: "", mark: null };
280
+ return owed;
251
281
  };
252
282
 
253
283
  /** How wide the body is where it is being written: the text column, divided
@@ -261,7 +291,8 @@ export let bodyOf = (section) => {
261
291
  /** @type {(event: any) => void} */
262
292
  let itemOf = (event) => {
263
293
  let style = styleOf(event);
264
- push(claimHeading(event.role), style, content(event, style, columnWidth(), ctx));
294
+ let { props, mark } = claim(event.role);
295
+ push(props, style, bookmark(mark, content(event, style, columnWidth(), ctx)));
265
296
  };
266
297
 
267
298
  /** @type {(event: any) => void} */
@@ -269,10 +300,12 @@ export let bodyOf = (section) => {
269
300
  block(splitBlock(event, columnWidth(), styleOf(event), ctx));
270
301
  };
271
302
 
272
- /** What a table's cells are written with. `intl` is settled on the opening
273
- * event, so it is read here rather than captured at compile.
274
- * @type {() => { pad: Record<string, number>, intl: any }} */
275
- let ruled = () => ({ pad: PAD, intl: ctx.intl });
303
+ /** What a table's cells are written with. `intl` and the link registry are
304
+ * settled on the opening event, so they are read here rather than captured
305
+ * at compile.
306
+ * @type {() => { pad: Record<string, number>, intl: any,
307
+ * link: (url: string) => string }} */
308
+ let ruled = () => ({ pad: PAD, intl: ctx.intl, link: ctx.link });
276
309
 
277
310
  /** @type {(event: any) => void} */
278
311
  let tableStart = (event) => {
package/lib/content.js CHANGED
@@ -12,13 +12,14 @@ import { looking, runs } from "./text.js";
12
12
  /**
13
13
  * What a render carries that the events do not: the report default every band
14
14
  * item wears under its own, the instance's `format` configuration, the text
15
- * column a picture is capped at, the registry a picture's bytes become a part
16
- * in, and -- for a page band only -- which field gives the length of the
17
- * sequence its section numbers. Both writers hand one of these down, so they
18
- * describe a render one way.
15
+ * column a picture is capped at, the registries a picture's bytes and a link's
16
+ * URL become relationships in, and -- for a page band only -- which field gives
17
+ * the length of the sequence its section numbers. Both writers hand one of
18
+ * these down, so they describe a render one way.
19
19
  *
20
20
  * @typedef {{ base: any, intl: any, width: number,
21
- * picture: (event: any) => string, sequence?: string }} Ctx
21
+ * picture: (event: any) => string, link: (url: string) => string,
22
+ * sequence?: string }} Ctx
22
23
  */
23
24
 
24
25
  /**
@@ -29,7 +30,7 @@ import { looking, runs } from "./text.js";
29
30
  * @type {(event: any, style: any, column: number, ctx: Ctx) => string}
30
31
  */
31
32
  export let content = (event, style, column, ctx) => {
32
- if (event.type !== "image") return runs(event.tokens, looking(style, ctx.intl, ctx.sequence));
33
+ if (event.type !== "image") return runs(event.tokens, looking(style, ctx));
33
34
  let rId = ctx.picture(event);
34
35
  return drawing(event, idOf(rId), rId, describe(event), sizeOf(event, column));
35
36
  };
package/lib/furniture.js CHANGED
@@ -22,6 +22,7 @@
22
22
  import { splits } from "quario";
23
23
  import { content, splitBlock } from "./content.js";
24
24
  import { paraProps, under } from "./style.js";
25
+ import { relationships } from "./rels.js";
25
26
  import { paragraph, run } from "./text.js";
26
27
  import { R, W, XML } from "./xml.js";
27
28
 
@@ -46,7 +47,7 @@ const MARKING_LOOK = '<w:color w:val="808080"/><w:sz w:val="14"/>';
46
47
  */
47
48
  let banded = (events, ctx) => {
48
49
  let out = "";
49
- for (let e of splits(events)) out += STEPS[e.type]?.(e, ctx) ?? "";
50
+ for (let e of splits(events)) out += STEPS[e.type](e, ctx);
50
51
  return out;
51
52
  };
52
53
 
@@ -89,14 +90,20 @@ let reading = (closure, at, lead, ctx) => lead + (closure ? banded(closure(at),
89
90
  * `reset: "page"` all number themselves the same way, so a document of two
90
91
  * hundred invoices carries one footer part.
91
92
  *
92
- * @param {any} [page] `report-start.page`, absent when no band is declared.
93
- * @param {string} [marking] The wording, on an unlicensed render.
94
- * @param {(kind: string, target: string) => string} [relate] Registers a part
95
- * and returns the relationship that reaches it; the caller owns the document's
96
- * one allocator, because the body draws pictures too.
97
- * @param {Ctx} [ctx] What a render carries that the events do not.
93
+ * A band's own picture or link is a relationship of *its* part rather than of
94
+ * `document.xml`, so each reading records what it allocated and the part is
95
+ * written beside its own `.rels`. A reader that cannot resolve an `r:id` in the
96
+ * part it reads it from has a broken reference, however well the document
97
+ * resolves the same id.
98
+ *
99
+ * @param {any} page `report-start.page`, absent when no band is declared.
100
+ * @param {string} marking The wording, on an unlicensed render.
101
+ * @param {{ rels: any[], relate: (kind: string, target: string) => string }}
102
+ * registry The document's one relationship registry; the caller owns it,
103
+ * because the body draws pictures and links too.
104
+ * @param {Ctx} ctx What a render carries that the events do not.
98
105
  */
99
- export let furnish = (page, marking, relate = () => "", ctx = EMPTY_CTX) => {
106
+ export let furnish = (page, marking, registry, ctx) => {
100
107
  /** @type {Record<string, string>} */
101
108
  let parts = {};
102
109
  /** @type {Map<string, string>} */
@@ -104,17 +111,34 @@ export let furnish = (page, marking, relate = () => "", ctx = EMPTY_CTX) => {
104
111
  /** @type {Record<string, number>} */
105
112
  let counts = { header: 0, footer: 0 };
106
113
 
107
- // A part for this body, or the one already written for it.
108
- /** @type {(kind: string, body: string) => string} */
109
- let partFor = (kind, body) => {
114
+ // What the reading being taken has allocated: replaced per reading, and
115
+ // closed over by the wrappers below, so each part learns its own.
116
+ /** @type {Set<string>} */
117
+ let used = new Set();
118
+ /** @type {(allocate: any) => any} */
119
+ let recording = (allocate) => (/** @type {any} */ of) => {
120
+ let rId = allocate(of);
121
+ used.add(rId);
122
+ return rId;
123
+ };
124
+
125
+ // A part for this body, or the one already written for it. Identical markup
126
+ // refers to identical ids -- a picture is one part however often it is drawn
127
+ // -- so a part found by its markup is found with its relationships too.
128
+ /** @type {(kind: string, reading: { xml: string, used: Set<string> }) => string} */
129
+ let partFor = (kind, reading) => {
110
130
  let tag = kind === "header" ? "hdr" : "ftr";
111
- let xml = XML + `<w:${tag} ${W}>` + body + `</w:${tag}>`;
131
+ let xml = XML + `<w:${tag} ${W}>` + reading.xml + `</w:${tag}>`;
112
132
  let seen = byXml.get(xml);
113
133
  if (seen) return seen;
114
134
  let target = kind + ++counts[kind] + ".xml";
115
- let rId = relate(kind, target);
135
+ let rId = registry.relate(kind, target);
116
136
  byXml.set(xml, rId);
117
137
  parts["word/" + target] = xml;
138
+ if (reading.used.size)
139
+ parts["word/_rels/" + target + ".rels"] = relationships(
140
+ registry.rels.filter((rel) => reading.used.has(rel.rId)),
141
+ );
118
142
  return rId;
119
143
  };
120
144
 
@@ -123,11 +147,11 @@ export let furnish = (page, marking, relate = () => "", ctx = EMPTY_CTX) => {
123
147
  // no `first` reference shows *nothing* on page one once the other band has
124
148
  // turned it on. Both therefore name a first-page part whenever either needs
125
149
  // one; the steady one names the part it already wrote.
126
- /** @type {(kind: string, pair: string[], titlePg: boolean) => string} */
150
+ /** @type {(kind: string, pair: any[], titlePg: boolean) => string} */
127
151
  let references = (kind, [first, later], titlePg) => {
128
- /** @type {(type: string, body: string) => string} */
129
- let ref = (type, body) =>
130
- `<w:${kind}Reference w:type="${type}" r:id="${partFor(kind, body)}"/>`;
152
+ /** @type {(type: string, reading: any) => string} */
153
+ let ref = (type, reading) =>
154
+ `<w:${kind}Reference w:type="${type}" r:id="${partFor(kind, reading)}"/>`;
131
155
  return (titlePg ? ref("first", first) : "") + ref("default", later);
132
156
  };
133
157
 
@@ -140,7 +164,12 @@ export let furnish = (page, marking, relate = () => "", ctx = EMPTY_CTX) => {
140
164
  * @type {(sequence: string) => { refs: string, titlePg: boolean }}
141
165
  */
142
166
  let refsFor = (sequence) => {
143
- let band = { ...ctx, sequence };
167
+ let band = {
168
+ ...ctx,
169
+ sequence,
170
+ picture: recording(ctx.picture),
171
+ link: recording(ctx.link),
172
+ };
144
173
  let lead = marking ? paragraph("", run(marking, MARKING_LOOK)) : "";
145
174
  let bands = [
146
175
  { kind: "header", closure: page?.header, lead: "" },
@@ -149,9 +178,12 @@ export let furnish = (page, marking, relate = () => "", ctx = EMPTY_CTX) => {
149
178
  .filter((band) => band.closure || band.lead)
150
179
  .map(({ kind, closure, lead: own }) => ({
151
180
  kind,
152
- pair: PROBE.map((at) => reading(closure, at, own, band)),
181
+ pair: PROBE.map((at) => {
182
+ used = new Set();
183
+ return { xml: reading(closure, at, own, band), used };
184
+ }),
153
185
  }));
154
- let titlePg = bands.some(({ pair }) => pair[0] !== pair[1]);
186
+ let titlePg = bands.some(({ pair }) => pair[0].xml !== pair[1].xml);
155
187
  return {
156
188
  refs: bands.map(({ kind, pair }) => references(kind, pair, titlePg)).join(""),
157
189
  titlePg,
@@ -161,8 +193,14 @@ export let furnish = (page, marking, relate = () => "", ctx = EMPTY_CTX) => {
161
193
  return { parts, refsFor };
162
194
  };
163
195
 
164
- /** A render that declares no bands and marks nothing needs none of it. */
165
- export const EMPTY_CTX = { base: null, intl: null, width: 0, picture: () => "" };
196
+ /** A document nothing has furnished yet: the value a render carries until its
197
+ * opening event says what its page bands are. A stream walked from part-way
198
+ * through never replaces it, and still describes a page. */
199
+ /** @type {{ parts: Record<string, string>, refsFor: (sequence: string) => { refs: string, titlePg: boolean } }} */
200
+ export const NO_FURNITURE = {
201
+ parts: {},
202
+ refsFor: () => ({ refs: "", titlePg: false }),
203
+ };
166
204
 
167
205
  /** The namespaces a document referencing furniture declares. */
168
206
  export const DOCUMENT_NS = W + " " + R;
package/lib/index.d.ts CHANGED
@@ -34,3 +34,27 @@ export interface DocxOptions {
34
34
  * bytes described in SCHEMA.md ("The DOCX target").
35
35
  */
36
36
  export function docx(options?: DocxOptions): Target<"docx", Promise<Uint8Array>>;
37
+
38
+ /** What a target makes of a declaration: CONTEXT.md's fate vocabulary. */
39
+ export type Fate = "resolved" | "approximated" | "withdrawn" | "unread";
40
+
41
+ /**
42
+ * What this target makes of each declaration SCHEMA.md's support matrix
43
+ * names. Hand-written and gated against that matrix rather than generated
44
+ * from it. It carries no version of its own: the package that ships it is the
45
+ * version.
46
+ *
47
+ * Keyed by the schema name an author writes — `valign`, `break`, `page.margin`
48
+ * — so a host, a `required` marking and a problem message share one
49
+ * vocabulary. Two matrix rows share the bare word `width`, so each is
50
+ * qualified by what carries it: `column.width` and `slot.width`.
51
+ *
52
+ * It covers the declarations every official target states one fate for. An
53
+ * absent key means the matrix states no single fate for that row — a row that
54
+ * summarises more than one declaration, or that describes a behaviour — never
55
+ * that this target has no answer.
56
+ */
57
+ export const capabilities: {
58
+ readonly target: "docx";
59
+ readonly declarations: Readonly<Record<string, Fate>>;
60
+ };
package/lib/index.js CHANGED
@@ -9,18 +9,16 @@
9
9
  * stream, and the parts a package is made of. The writers live beside it:
10
10
  * `body.js`, `furniture.js`, `styles.js` and `text.js`.
11
11
  */
12
- import { hostMeta, hostOptions, splits, walk } from "quario";
12
+ import { collapse, hostMeta, hostOptions, splits, walk } from "quario";
13
13
  import { bodyOf } from "./body.js";
14
- import { DOCUMENT_NS, furnish } from "./furniture.js";
14
+ import { DOCUMENT_NS, furnish, NO_FURNITURE } from "./furniture.js";
15
15
  import { STYLES } from "./stylepart.js";
16
16
  import { DOCUMENT_PAGES, SECTION_PAGES } from "./text.js";
17
17
  import { geometry, pageBox, twips } from "./page.js";
18
18
  import { pack } from "./pack.js";
19
+ import { overrides, packageRels, relationships } from "./rels.js";
19
20
  import { W, XML, esc } from "./xml.js";
20
21
 
21
- const RELS = 'xmlns="http://schemas.openxmlformats.org/package/2006/relationships"';
22
- const OFFICE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
23
- const PACKAGE = "http://schemas.openxmlformats.org/package/2006/relationships";
24
22
  const WML = "application/vnd.openxmlformats-officedocument.wordprocessingml";
25
23
 
26
24
  // The distance from the paper's edge to a header or a footer, in twips: Word's
@@ -75,9 +73,9 @@ let core = (meta) =>
75
73
  * refs: string, titlePg: boolean }} Section */
76
74
 
77
75
  // The parts every document relates to, in relationship order. A furniture part
78
- // numbers from after them, and both the content types and the relationships
79
- // below are one map over the pair, so a part cannot reach the package through
80
- // one and not the other.
76
+ // numbers from after them, and `rels.js` maps the whole list to both the
77
+ // content types and the relationships, so a part cannot reach the package
78
+ // through one and not the other.
81
79
  // A picture's bytes are typed by extension rather than by part name, the way
82
80
  // `rels` and `xml` already are: one `Default` covers every image of that kind.
83
81
  const IMAGE_TYPES = [
@@ -86,20 +84,10 @@ const IMAGE_TYPES = [
86
84
  ];
87
85
 
88
86
  const FIXED = [
89
- { path: "word/settings.xml", target: "settings.xml", kind: "settings", rId: "rId1" },
90
- { path: "word/styles.xml", target: "styles.xml", kind: "styles", rId: "rId2" },
87
+ { target: "settings.xml", kind: "settings", rId: "rId1" },
88
+ { target: "styles.xml", kind: "styles", rId: "rId2" },
91
89
  ];
92
90
 
93
- // The content type of one furniture part, and the relationship that reaches
94
- // it. Both are keyed off the same `{ path, kind, rId }` the registry hands
95
- // back, so a part cannot reach the package through one and not the other.
96
- /** @type {(part: { path: string, kind: string }) => string} */
97
- let override = ({ path, kind }) =>
98
- `<Override PartName="/${path}" ContentType="${WML}.${kind}+xml"/>`;
99
- /** @type {(part: { target: string, kind: string, rId: string }) => string} */
100
- let related = ({ target, kind, rId }) =>
101
- `<Relationship Id="${rId}" Type="${OFFICE}/${kind}" Target="${target}"/>`;
102
-
103
91
  /**
104
92
  * The parts of the package. The floor is what Word opens: the content types,
105
93
  * the package relationships and `document.xml` — plus `settings.xml`, which is
@@ -116,7 +104,7 @@ let parts = (body, meta, furniture, rels) => ({
116
104
  '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
117
105
  '<Default Extension="xml" ContentType="application/xml"/>' +
118
106
  `<Override PartName="/word/document.xml" ContentType="${WML}.document.main+xml"/>` +
119
- rels.map(override).join("") +
107
+ overrides(rels) +
120
108
  IMAGE_TYPES.filter(([ext]) => rels.some((r) => r.target.endsWith("." + ext)))
121
109
  .map(([ext, type]) => `<Default Extension="${ext}" ContentType="image/${type}"/>`)
122
110
  .join("") +
@@ -124,17 +112,9 @@ let parts = (body, meta, furniture, rels) => ({
124
112
  ? '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
125
113
  : "") +
126
114
  "</Types>",
127
- "_rels/.rels":
128
- XML +
129
- `<Relationships ${RELS}>` +
130
- `<Relationship Id="rId1" Type="${OFFICE}/officeDocument" Target="word/document.xml"/>` +
131
- (meta
132
- ? `<Relationship Id="rId2" Type="${PACKAGE}/metadata/core-properties" Target="docProps/core.xml"/>`
133
- : "") +
134
- "</Relationships>",
115
+ "_rels/.rels": packageRels(meta),
135
116
  "word/document.xml": XML + `<w:document ${DOCUMENT_NS}><w:body>${body}</w:body></w:document>`,
136
- "word/_rels/document.xml.rels":
137
- XML + `<Relationships ${RELS}>` + rels.map(related).join("") + "</Relationships>",
117
+ "word/_rels/document.xml.rels": relationships(rels),
138
118
  "word/settings.xml":
139
119
  XML +
140
120
  `<w:settings ${W}><w:compat>` +
@@ -178,16 +158,20 @@ export function docx(options) {
178
158
  // always has, then whatever the furniture and the body ask for. One
179
159
  // allocator, because a page band and the body both draw pictures and two
180
160
  // would hand out the same number twice.
181
- /** @type {{ path: string, target: string, kind: string, rId: string }[]} */
161
+ /** @type {import('./rels.js').Rel[]} */
182
162
  let rels = [...FIXED];
183
163
  /** @type {Record<string, Uint8Array>} */
184
164
  let media = {};
185
165
  /** @type {(kind: string, target: string) => string} */
186
166
  let relate = (kind, target) => {
187
167
  let rId = "rId" + (rels.length + 1);
188
- rels.push({ path: "word/" + target, target, kind, rId });
168
+ rels.push({ target, kind, rId });
189
169
  return rId;
190
170
  };
171
+ // The one registry, handed to whatever writes a part: the furniture needs
172
+ // the records and not only the ids, because a part's own relationships are
173
+ // the ones its markup refers to.
174
+ let registry = { rels, relate };
191
175
  // One part per distinct picture, keyed on the bytes themselves: the
192
176
  // page-band probe reads each band twice and every section reads it again,
193
177
  // so the part stays identical and the furniture's dedup still collapses
@@ -195,6 +179,18 @@ export function docx(options) {
195
179
  // per call, which is wasteful and still correct.
196
180
  /** @type {Map<Uint8Array, string>} */
197
181
  let drawn = new Map();
182
+ // One relationship per distinct URL, for the same reason: a report linking
183
+ // one place from every row would otherwise relate to it once per row.
184
+ /** @type {Map<string, string>} */
185
+ let linked = new Map();
186
+ /** @type {(url: string) => string} */
187
+ let link = (url) => {
188
+ let seen = linked.get(url);
189
+ if (seen) return seen;
190
+ let rId = relate("hyperlink", url);
191
+ linked.set(url, rId);
192
+ return rId;
193
+ };
198
194
  /** @type {(event: any) => string} */
199
195
  let picture = (event) => {
200
196
  let seen = drawn.get(event.bytes);
@@ -205,7 +201,7 @@ export function docx(options) {
205
201
  drawn.set(event.bytes, rId);
206
202
  return rId;
207
203
  };
208
- let furniture = furnish();
204
+ let furniture = NO_FURNITURE;
209
205
 
210
206
  /** @type {(kind: any) => Section} */
211
207
  let section = (kind) => ({
@@ -213,7 +209,7 @@ export function docx(options) {
213
209
  ...furniture.refsFor(kind.reset ? SECTION_PAGES : DOCUMENT_PAGES),
214
210
  });
215
211
  let body = bodyOf(section);
216
- await walk(splits(stream(data)), {
212
+ await walk(collapse(splits(stream(data))), {
217
213
  ...body.handlers,
218
214
  "report-start": (event) => {
219
215
  geo = geometry(page, event);
@@ -225,8 +221,9 @@ export function docx(options) {
225
221
  intl: { locale: event.locale, currency: event.currency, timeZone: event.timeZone },
226
222
  width: geo.width - 2 * geo.margin,
227
223
  picture,
224
+ link,
228
225
  };
229
- furniture = furnish(event.page, event.marking, relate, ctx);
226
+ furniture = furnish(event.page, event.marking ?? "", registry, ctx);
230
227
  body.start(ctx, event);
231
228
  },
232
229
  });
@@ -246,3 +243,53 @@ export function docx(options) {
246
243
  };
247
244
  return { name: "docx", compile };
248
245
  }
246
+
247
+ /**
248
+ * What this target makes of each declaration the support matrix names: one of
249
+ * the [fates](../../../CONTEXT.md#declaration-and-resolution) — `resolved`,
250
+ * `approximated`, `withdrawn` or `unread` — so a host can ask before it
251
+ * renders rather than reading the prose.
252
+ *
253
+ * **Hand-written, and gated rather than generated.** SCHEMA.md's matrix is the
254
+ * description a person maintains and this is the data a host reads; the repo's
255
+ * `test/capabilities.test.js` holds the two equal, which is the same bargain
256
+ * ADR 0014 makes for the band-role table. It carries no version of its own:
257
+ * the package that ships it is the version, and a second copy could only drift
258
+ * from it.
259
+ *
260
+ * Keyed by the **schema name** an author writes, so a host, a `required`
261
+ * marking and a problem message all index by one vocabulary. Two rows share
262
+ * the bare word `width`, so each is qualified by what carries it.
263
+ *
264
+ * It covers the declarations every official target states one fate for. A
265
+ * matrix row that summarises more than one declaration, or that describes a
266
+ * behaviour rather than a declaration, states no single fate and is absent
267
+ * here — an absent key means the spec does not answer, never that this target
268
+ * has no answer.
269
+ */
270
+ export const capabilities = Object.freeze({
271
+ target: "docx",
272
+ declarations: Object.freeze({
273
+ "page.header": "resolved",
274
+ "page.footer": "resolved",
275
+ break: "resolved",
276
+ reset: "resolved",
277
+ "page.margin": "resolved",
278
+ "header.height": "withdrawn",
279
+ groups: "resolved",
280
+ label: "approximated",
281
+ collapsed: "resolved",
282
+ "column.width": "approximated",
283
+ "slot.width": "resolved",
284
+ span: "resolved",
285
+ uppercase: "resolved",
286
+ href: "resolved",
287
+ spaceBefore: "resolved",
288
+ spaceAfter: "resolved",
289
+ format: "resolved",
290
+ family: "resolved",
291
+ valign: "resolved",
292
+ runs: "resolved",
293
+ alt: "resolved",
294
+ }),
295
+ });
package/lib/rels.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The package's relationships: what a part may reach, and the id it refers to
3
+ * it by. A relationship is per part, so `document.xml` has its own and so does
4
+ * every header and footer — a picture a page band draws is reachable from the
5
+ * band's part or from nowhere.
6
+ *
7
+ * Two things a relationship says beyond its target. A **hyperlink** names a URL
8
+ * rather than a part, so it says `TargetMode` and the package types no content
9
+ * for it. Every other kind reaches a part this target packs, and of those the
10
+ * WordprocessingML ones each need a content type of their own — a picture's
11
+ * bytes are typed by their extension instead, one entry however many pictures
12
+ * there are.
13
+ */
14
+ import { XML, esc } from "./xml.js";
15
+
16
+ const RELS = 'xmlns="http://schemas.openxmlformats.org/package/2006/relationships"';
17
+ const OFFICE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
18
+ const PACKAGE = "http://schemas.openxmlformats.org/package/2006/relationships";
19
+ const WML = "application/vnd.openxmlformats-officedocument.wordprocessingml";
20
+
21
+ /** The one kind whose target leaves the package. */
22
+ const EXTERNAL = "hyperlink";
23
+
24
+ /** The kinds whose target is a WordprocessingML part of this package. */
25
+ const TYPED = new Set(["settings", "styles", "header", "footer"]);
26
+
27
+ /** @typedef {{ target: string, kind: string, rId: string }} Rel */
28
+
29
+ /** One relationship. The target is a part name this target wrote or a URL the
30
+ * engine admitted, and the escape is the same one every other string goes
31
+ * through on the way out.
32
+ * @type {(rel: Rel) => string} */
33
+ let related = ({ target, kind, rId }) =>
34
+ `<Relationship Id="${rId}" Type="${OFFICE}/${kind}" Target="${esc(target)}"` +
35
+ (kind === EXTERNAL ? ' TargetMode="External"' : "") +
36
+ "/>";
37
+
38
+ /** One relationships part, whether the package's or one part's own.
39
+ * @type {(rels: Rel[]) => string} */
40
+ export let relationships = (rels) =>
41
+ XML + `<Relationships ${RELS}>` + rels.map(related).join("") + "</Relationships>";
42
+
43
+ /** The content types the relationships of one package earn: one per
44
+ * WordprocessingML part they reach.
45
+ * @type {(rels: Rel[]) => string} */
46
+ export let overrides = (rels) =>
47
+ rels
48
+ .filter(({ kind }) => TYPED.has(kind))
49
+ .map(
50
+ ({ target, kind }) =>
51
+ `<Override PartName="/word/${target}" ContentType="${WML}.${kind}+xml"/>`,
52
+ )
53
+ .join("");
54
+
55
+ /** The package's own relationships: `document.xml`, and the metadata part a
56
+ * host's `meta` wrote. Its two entries name types from two vocabularies, which
57
+ * is why they are spelled here rather than through `related`.
58
+ * @type {(meta: any) => string} */
59
+ export let packageRels = (meta) =>
60
+ XML +
61
+ `<Relationships ${RELS}>` +
62
+ `<Relationship Id="rId1" Type="${OFFICE}/officeDocument" Target="word/document.xml"/>` +
63
+ (meta
64
+ ? `<Relationship Id="rId2" Type="${PACKAGE}/metadata/core-properties" Target="docProps/core.xml"/>`
65
+ : "") +
66
+ "</Relationships>";
package/lib/stylepart.js CHANGED
@@ -24,11 +24,13 @@
24
24
  */
25
25
  import { W, XML } from "./xml.js";
26
26
 
27
- // The base. Arial is the name the sans generic maps to; 10 pt is `sz` in
28
- // half-points; 1.4 line spacing is `line` in 240ths of a line, `auto` so a
29
- // larger run still fits.
27
+ // The base. Arial is the name the sans generic maps to; `BASE` is the type
28
+ // size in points, `sz` in half-points; 1.4 line spacing is `line` in 240ths
29
+ // of a line, `auto` so a larger run still fits. The layout carries the same
30
+ // 10 (docs/adr/0014).
30
31
  const FAMILY = "Arial";
31
- const SIZE = 20;
32
+ export const BASE = 10;
33
+ const SIZE = BASE * 2;
32
34
  const LINE = 336;
33
35
 
34
36
  /** Word caps the outline at nine levels, and so does the deepest style here. */
package/lib/table.js CHANGED
@@ -63,7 +63,7 @@ let tc = (cell, width, body, pad) =>
63
63
  (cell.span > 1 ? `<w:gridSpan w:val="${cell.span}"/>` : "") +
64
64
  cellProps(cell.style, pad) +
65
65
  "</w:tcPr>" +
66
- (body || "<w:p/>") +
66
+ body +
67
67
  "</w:tc>";
68
68
 
69
69
  /**
@@ -125,7 +125,7 @@ export let splitting = (slots, width, style) => {
125
125
  return {
126
126
  style,
127
127
  slot: (own, content) => {
128
- let column = widths[at++] || 0;
128
+ let column = widths[at++];
129
129
  // `boxed: false`: the slot's box is the cell's, so the paragraph inside
130
130
  // it must not draw a second one.
131
131
  cells += tc(
@@ -142,12 +142,12 @@ export let splitting = (slots, width, style) => {
142
142
  /**
143
143
  * How wide one cell is, in twips: its own grid column, plus every column it
144
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.
145
+ * the column it has reached, since a span consumes more than one. It is also
146
+ * where a span becomes a count of at least one, so this one takes it as read.
146
147
  *
147
148
  * @type {(widths: number[], at: number, span: number) => number}
148
149
  */
149
- let spanned = (widths, at, span) =>
150
- widths.slice(at, at + Math.max(1, span || 1)).reduce((sum, w) => sum + w, 0);
150
+ let spanned = (widths, at, span) => widths.slice(at, at + span).reduce((sum, w) => sum + w, 0);
151
151
 
152
152
  /**
153
153
  * One row of cells, each wearing what the row layers over it and taking the
@@ -158,7 +158,8 @@ let spanned = (widths, at, span) =>
158
158
  * page band binds the `page` anchor, which is why no sequence is named here.
159
159
  *
160
160
  * @type {(cells: any[], widths: number[], heading: boolean, layer: any,
161
- * how: { pad: Record<string, number>, intl: any }) => string}
161
+ * how: { pad: Record<string, number>, intl: any,
162
+ * link: (url: string) => string }) => string}
162
163
  */
163
164
  export let record = (cells, widths, heading, layer, how) => {
164
165
  let at = 0;
@@ -171,7 +172,7 @@ export let record = (cells, widths, heading, layer, how) => {
171
172
  spanned(widths, at, span),
172
173
  // `boxed: false`: the cell above draws the box, so the paragraph inside
173
174
  // it must not draw a second one.
174
- paragraph(paraProps(style, false), runs(cell.tokens, looking(style, how.intl))),
175
+ paragraph(paraProps(style, false), runs(cell.tokens, looking(style, how))),
175
176
  how.pad,
176
177
  );
177
178
  at += span;
package/lib/text.js CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { display, format, styledRuns } from "quario";
20
20
  import { capitals, runProps } from "./style.js";
21
- import { esc } from "./xml.js";
21
+ import { R, esc } from "./xml.js";
22
22
 
23
23
  /** One paragraph, its properties already written.
24
24
  * @type {(props: string, inner: string) => string} */
@@ -104,38 +104,90 @@ let shown = (token, look) => format(token.value, look.style, look.intl) ?? displ
104
104
  /**
105
105
  * What a stretch of text is written with: the resolved style it wears, the run
106
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.
107
+ * `format` configuration, which field gives the current sequence's length, and
108
+ * the registry a link's URL becomes a relationship in. Carried together because
109
+ * every one of them is read for every token, and derived once per run rather
110
+ * than per token — `sequence` included, which is why nothing below it takes one
111
+ * as an argument.
111
112
  *
112
113
  * @typedef {{ style: any, props: string, caps: boolean, intl: any,
113
- * sequence: string }} Look
114
+ * sequence: string, link: (url: string) => string }} Look
114
115
  */
115
116
 
116
- /** @type {(style: any, intl: any, sequence?: string, boxed?: boolean) => Look} */
117
- export let looking = (style, intl, sequence = DOCUMENT_PAGES, boxed = false) => ({
117
+ /** A `Look` from what the render already carries `content.js`'s `Ctx`, a
118
+ * table's `how`, or an outer `Look`. A body cell names no sequence, because
119
+ * only a page band binds the `page` anchor.
120
+ * @type {(style: any, of: { intl: any, sequence?: string,
121
+ * link: (url: string) => string }, boxed?: boolean) => Look} */
122
+ export let looking = (style, of, boxed = false) => ({
118
123
  style,
119
124
  props: runProps(style, boxed),
120
125
  caps: capitals(style),
121
- intl,
122
- sequence,
126
+ intl: of.intl,
127
+ sequence: of.sequence || DOCUMENT_PAGES,
128
+ link: of.link,
123
129
  });
124
130
 
131
+ /**
132
+ * A run that carries an [`href`](../../../SCHEMA.md#style-declarations), inside
133
+ * its hyperlink. The engine admitted the URL against the host's scheme
134
+ * allowlist before it crossed (`docs/adr/0085`), so this target escapes it like
135
+ * every other string and decides nothing about it.
136
+ *
137
+ * An `href` beginning `#` names a group's `label`, which is the bookmark below;
138
+ * anything else is a URL, and a URL leaves the package, so it is a relationship
139
+ * of the part the run lands in. A run with no text is no link: there would be
140
+ * nothing to click, and the relationship would reach out of the document for
141
+ * nobody.
142
+ *
143
+ * The relationship namespace is declared on the element that uses it, as the
144
+ * picture's `blip` declares it: a page band's part carries no `r` of its own,
145
+ * and a part that opened one only where a link happened to land would be a
146
+ * second rule about where a namespace comes from.
147
+ *
148
+ * @type {(inner: string, look: Look) => string}
149
+ */
150
+ let linked = (inner, look) => {
151
+ let href = look.style?.href;
152
+ if (!href || !inner) return inner;
153
+ let at = href.startsWith("#")
154
+ ? `w:anchor="${esc(href.slice(1))}"`
155
+ : `${R} r:id="${look.link(href)}"`;
156
+ return `<w:hyperlink ${at}>` + inner + "</w:hyperlink>";
157
+ };
158
+
159
+ /**
160
+ * A destination inside the document: the bookmark an instance's
161
+ * [`label`](../../../SCHEMA.md#group-definition) names, around the content of
162
+ * the item that claimed it. The name is the label itself, which is what an
163
+ * `href` of `#label` asks for; the id is Word's own, an integer the document
164
+ * uses once.
165
+ *
166
+ * @type {(mark: { id: number, name: string } | null, inner: string) => string}
167
+ */
168
+ export let bookmark = (mark, inner) =>
169
+ mark
170
+ ? `<w:bookmarkStart w:id="${mark.id}" w:name="${esc(mark.name)}"/>` +
171
+ inner +
172
+ `<w:bookmarkEnd w:id="${mark.id}"/>`
173
+ : inner;
174
+
125
175
  /**
126
176
  * A cell's tokens as Word runs. The engine's `styledRuns` is the grouping, so
127
177
  * a styled run's own resolved style replaces the cell's for its stretch — the
128
178
  * two are already composed on the stream — and a tagged token stays a field
129
- * whatever style it wears.
179
+ * whatever style it wears. A run carrying an `href` sits inside its hyperlink,
180
+ * which is an element of the paragraph rather than a run property.
130
181
  *
131
182
  * @type {(tokens: any[], look: Look) => string}
132
183
  */
133
184
  export let runs = (tokens, look) =>
134
185
  styledRuns(tokens)
135
186
  .map((styled) => {
136
- let own = styled.style ? looking(styled.style, look.intl, look.sequence, true) : look;
137
- return styled.tokens
187
+ let own = styled.style ? looking(styled.style, look, true) : look;
188
+ let inner = styled.tokens
138
189
  .map((token) => fieldOf(token, own.sequence) || run(textOf(token, own), own.props))
139
190
  .join("");
191
+ return linked(inner, own);
140
192
  })
141
193
  .join("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/docx",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Tiny, flow-based Word render target for quario. Real Word tables, a navigable outline, live page-number fields.",
5
5
  "keywords": [
6
6
  "csp",
@@ -33,12 +33,13 @@
33
33
  "access": "public"
34
34
  },
35
35
  "scripts": {
36
- "check": "npm run size && npm test && npm run test:browser",
36
+ "check": "npm run size && npm test",
37
+ "coverage:check": "c8 report --src lib/ --temp-directory=../../coverage/tmp --reporter=text --check-coverage --100",
37
38
  "size": "size-limit",
38
- "test": "npm run test:unit && npm run test:types",
39
+ "test": "npm run test:unit && npm run test:browser && npm run test:types && npm run coverage:check",
39
40
  "test:browser": "node test/browser/setup.js",
40
41
  "test:types": "tsc && attw --pack . --profile esm-only",
41
- "test:unit": "node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
42
+ "test:unit": "c8 --clean=false --src lib/ --reporter=none --temp-directory=../../coverage/tmp node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
42
43
  "prepack": "node -e \"require('fs').copyFileSync('../../LICENSE','LICENSE')\"",
43
44
  "postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\""
44
45
  },
@@ -48,12 +49,12 @@
48
49
  "devDependencies": {
49
50
  "@arethetypeswrong/cli": "^0.18.3",
50
51
  "@size-limit/preset-small-lib": "^13.0.3",
51
- "quario": "^0.10.0",
52
+ "quario": "^0.11.0",
52
53
  "size-limit": "^13.0.3",
53
54
  "typescript": "^7.0.2"
54
55
  },
55
56
  "peerDependencies": {
56
- "quario": "^0.10.0"
57
+ "quario": "^0.11.0"
57
58
  },
58
59
  "size-limit": [
59
60
  {