@quario/docx 0.1.1 → 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/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/style.js CHANGED
@@ -1,9 +1,7 @@
1
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.
2
+ * This target's reading of the closed style vocabulary. Declarations are
3
+ * lenient at render: a value of the wrong shape contributes nothing rather
4
+ * than reaching the document.
7
5
  *
8
6
  * Everything here is a property *string*, in the order the schema fixes.
9
7
  * `CT_RPr`, `CT_PPrBase`, `CT_TcPr` and the border containers are sequences,
@@ -22,10 +20,9 @@ let HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
22
20
 
23
21
  // A border container's sides, in the order `CT_PBdr` and `CT_TcBorders` both
24
22
  // 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.
23
+ // names them in. Built once: `borders` runs per paragraph and per cell, and
24
+ // rebuilding four key strings per side per call is most of this module's
25
+ // garbage.
29
26
  let SIDES = ["Top", "Left", "Bottom", "Right"].map((side) => ({
30
27
  tag: side.toLowerCase(),
31
28
  width: "border" + side + "Width",
@@ -74,9 +71,8 @@ let underline = (value) =>
74
71
  /**
75
72
  * The face a declared family names. A generic maps to the name all three
76
73
  * 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).
74
+ * reader to substitute and **escaped** — the one value in this module that is
75
+ * not a number, a hex colour or a closed name.
80
76
  *
81
77
  * @type {(family: any) => string}
82
78
  */
@@ -123,13 +119,10 @@ let tint = (value) => {
123
119
  * Unicode default case mapping, never the host's locale, exactly as the PDF
124
120
  * target does for its own reason (`text.js`).
125
121
  *
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.
122
+ * `boxed` is the same question `paraProps` asks below: *is this element the
123
+ * one that draws the box?* The box belongs to the innermost container that can
124
+ * hold one, which is why the default here is `false` and there `true`. Only a
125
+ * [styled run](../../../SCHEMA.md#cell-values) has nothing but the run.
133
126
  *
134
127
  * @type {(style: any, boxed?: boolean) => string}
135
128
  */
package/lib/stylepart.js CHANGED
@@ -1,8 +1,7 @@
1
1
  /**
2
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.
3
+ * Named for the part rather than the vocabulary: `style.js` beside it is this
4
+ * target's reading of what an author declares.
6
5
  *
7
6
  * `docDefaults` is the base every run and paragraph inherits: the sans
8
7
  * generic's own name, the report default's size, and the line spacing the
@@ -25,11 +24,13 @@
25
24
  */
26
25
  import { W, XML } from "./xml.js";
27
26
 
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.
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).
31
31
  const FAMILY = "Arial";
32
- const SIZE = 20;
32
+ export const BASE = 10;
33
+ const SIZE = BASE * 2;
33
34
  const LINE = 336;
34
35
 
35
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,7 +1,14 @@
1
1
  {
2
2
  "name": "@quario/docx",
3
- "version": "0.1.1",
4
- "description": "The Word render target for quario a flow target: real Word tables, a navigable outline, live page-number fields",
3
+ "version": "0.3.0",
4
+ "description": "Tiny, flow-based Word render target for quario. Real Word tables, a navigable outline, live page-number fields.",
5
+ "keywords": [
6
+ "csp",
7
+ "docx",
8
+ "ooxml",
9
+ "quario",
10
+ "word"
11
+ ],
5
12
  "homepage": "https://getquario.com",
6
13
  "license": "SEE LICENSE IN LICENSE",
7
14
  "repository": {
@@ -26,12 +33,13 @@
26
33
  "access": "public"
27
34
  },
28
35
  "scripts": {
29
- "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",
30
38
  "size": "size-limit",
31
- "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",
32
40
  "test:browser": "node test/browser/setup.js",
33
41
  "test:types": "tsc && attw --pack . --profile esm-only",
34
- "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",
35
43
  "prepack": "node -e \"require('fs').copyFileSync('../../LICENSE','LICENSE')\"",
36
44
  "postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\""
37
45
  },
@@ -41,12 +49,12 @@
41
49
  "devDependencies": {
42
50
  "@arethetypeswrong/cli": "^0.18.3",
43
51
  "@size-limit/preset-small-lib": "^13.0.3",
44
- "quario": "^0.9.0",
52
+ "quario": "^0.11.0",
45
53
  "size-limit": "^13.0.3",
46
54
  "typescript": "^7.0.2"
47
55
  },
48
56
  "peerDependencies": {
49
- "quario": "^0.9.0"
57
+ "quario": "^0.11.0"
50
58
  },
51
59
  "size-limit": [
52
60
  {