mosage 0.2.0 → 0.8.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/README.md +16 -15
- package/bin.js +2 -0
- package/dist/build-C7NW_3Pk.js +14 -0
- package/dist/check-CP4873Wx.js +41 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +228 -0
- package/dist/config-DPm1BBAb.js +2619 -0
- package/dist/config-TlTe7Ona.d.ts +24 -0
- package/dist/context-BqsdSrAQ.js +1084 -0
- package/dist/dev-Biz42qlu.js +17 -0
- package/dist/diagram-xlVDekYk.js +763 -0
- package/dist/export-Bi6nuxjT.js +31 -0
- package/dist/import-D2jNB07F.js +25 -0
- package/dist/index.d.ts +455 -0
- package/dist/index.js +693 -0
- package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
- package/dist/preview-CLm51aRt.js +19 -0
- package/dist/sdk-DjpX6mCv.js +51 -0
- package/dist/vite/index.d.ts +25 -0
- package/dist/vite/index.js +2 -0
- package/env.d.ts +83 -0
- package/package.json +59 -12
- package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
- package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
- package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
- package/src/app/app.tsx +42 -0
- package/src/app/components/data-table.tsx +196 -0
- package/src/app/components/design-panel/design-panel.tsx +318 -0
- package/src/app/components/design-panel/design-provider.tsx +121 -0
- package/src/app/components/design-panel/use-design.ts +85 -0
- package/src/app/components/diagram.tsx +76 -0
- package/src/app/components/doc-assets.tsx +129 -0
- package/src/app/components/doc-search.tsx +248 -0
- package/src/app/components/doc-sidebar.tsx +162 -0
- package/src/app/components/flow-page.tsx +93 -0
- package/src/app/components/footnote.tsx +204 -0
- package/src/app/components/image-placeholder.tsx +50 -0
- package/src/app/components/inspector/inspector.tsx +518 -0
- package/src/app/components/numbering.tsx +224 -0
- package/src/app/components/page-frame.tsx +70 -0
- package/src/app/components/sidebar/folder-item.tsx +212 -0
- package/src/app/components/sidebar/icon-picker.tsx +99 -0
- package/src/app/components/sidebar/sidebar.tsx +252 -0
- package/src/app/components/table-of-contents.tsx +93 -0
- package/src/app/components/theme-toggle.tsx +50 -0
- package/src/app/components/themes/markdown.tsx +249 -0
- package/src/app/components/themes/theme-preview.tsx +74 -0
- package/src/app/components/ui/menu.tsx +143 -0
- package/src/app/index.html +12 -0
- package/src/app/lib/agent-bridge.ts +140 -0
- package/src/app/lib/assets.ts +151 -0
- package/src/app/lib/design-presets.ts +109 -0
- package/src/app/lib/design.ts +88 -0
- package/src/app/lib/diagnostics.ts +282 -0
- package/src/app/lib/doc-preview.tsx +29 -0
- package/src/app/lib/docs.ts +26 -0
- package/src/app/lib/docx/extract.ts +1623 -0
- package/src/app/lib/docx/fonts.test.ts +136 -0
- package/src/app/lib/docx/fonts.ts +166 -0
- package/src/app/lib/docx/media.ts +102 -0
- package/src/app/lib/docx/model.ts +206 -0
- package/src/app/lib/docx/paragraph.test.ts +92 -0
- package/src/app/lib/docx/paragraph.ts +107 -0
- package/src/app/lib/docx/props.ts +187 -0
- package/src/app/lib/docx/styles.ts +306 -0
- package/src/app/lib/docx/units.ts +35 -0
- package/src/app/lib/docx/write.test.ts +507 -0
- package/src/app/lib/docx/write.ts +581 -0
- package/src/app/lib/docx/xml.ts +39 -0
- package/src/app/lib/export-docx.ts +289 -0
- package/src/app/lib/export-dom.ts +318 -0
- package/src/app/lib/export-html.ts +156 -0
- package/src/app/lib/export-image.ts +70 -0
- package/src/app/lib/export-pdf.ts +165 -0
- package/src/app/lib/flow-measure.test.ts +31 -0
- package/src/app/lib/flow-measure.ts +183 -0
- package/src/app/lib/flow.test.ts +110 -0
- package/src/app/lib/flow.ts +136 -0
- package/src/app/lib/folders.ts +192 -0
- package/src/app/lib/footnotes.test.tsx +102 -0
- package/src/app/lib/footnotes.ts +94 -0
- package/src/app/lib/inspector/fiber.ts +99 -0
- package/src/app/lib/labels.test.ts +18 -0
- package/src/app/lib/labels.ts +181 -0
- package/src/app/lib/outline.ts +118 -0
- package/src/app/lib/page-context.tsx +43 -0
- package/src/app/lib/page-range.test.ts +95 -0
- package/src/app/lib/page-range.ts +90 -0
- package/src/app/lib/print-ready.ts +69 -0
- package/src/app/lib/rasterize.ts +173 -0
- package/src/app/lib/scan.ts +26 -0
- package/src/app/lib/sdk.test.ts +32 -0
- package/src/app/lib/sdk.ts +115 -0
- package/src/app/lib/themes.ts +31 -0
- package/src/app/lib/use-doc-module.ts +53 -0
- package/src/app/lib/use-doc-pages.ts +147 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/lib/view-mode.test.ts +91 -0
- package/src/app/lib/view-mode.ts +104 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +257 -0
- package/src/app/routes/doc.tsx +877 -0
- package/src/app/routes/home-shell.tsx +203 -0
- package/src/app/routes/home.tsx +269 -0
- package/src/app/routes/themes.tsx +121 -0
- package/src/app/styles.css +97 -0
- package/src/app/virtual.d.ts +30 -0
- package/template/AGENTS.md +1 -1
- package/template/README.md +24 -52
- package/template/docs/getting-started/index.tsx +2 -2
- package/template/mosage.config.ts +1 -1
- package/template/package.json +1 -1
- package/template/tsconfig.json +1 -1
- package/README.zh-TW.md +0 -28
- /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
|
@@ -0,0 +1,1623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a rendered document back out of the DOM as a DocxModel.
|
|
3
|
+
*
|
|
4
|
+
* Everything comes from computed styles and measured boxes, never from the
|
|
5
|
+
* source: a hand-written page, an imported Markdown file, and a themed report
|
|
6
|
+
* all reach Word through the same door, and what Word gets is what the browser
|
|
7
|
+
* drew. Vertical spacing is measured between boxes rather than summed from
|
|
8
|
+
* margins, because CSS collapses adjoining margins and Word adds them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { FLOW_FOOTER_ATTR } from '../../components/flow-page';
|
|
12
|
+
import { FOOTNOTE_BODY_ATTR, FOOTNOTE_ROW_ATTR, FOOTNOTES_ATTR } from '../../components/footnote';
|
|
13
|
+
import { REF_ATTR, REF_PAGE_ATTR } from '../../components/numbering';
|
|
14
|
+
import {
|
|
15
|
+
TOC_DEPTH_ATTR,
|
|
16
|
+
TOC_ENTRY_ATTR,
|
|
17
|
+
TOC_LEVEL_ATTR,
|
|
18
|
+
TOC_PAGE_ATTR,
|
|
19
|
+
} from '../../components/table-of-contents';
|
|
20
|
+
import { blockHints } from '../flow-measure';
|
|
21
|
+
import { LABEL_ATTR, LABEL_ID_ATTR } from '../labels';
|
|
22
|
+
import { HEADING_SELECTOR, levelOf, outlineLevelOf, TOC_ATTR } from '../outline';
|
|
23
|
+
import { type FontDeclaration, fontKindOf, pickFonts } from './fonts';
|
|
24
|
+
import { MediaStore } from './media';
|
|
25
|
+
import type {
|
|
26
|
+
Block,
|
|
27
|
+
Border,
|
|
28
|
+
Borders,
|
|
29
|
+
DocxModel,
|
|
30
|
+
FontSet,
|
|
31
|
+
Inline,
|
|
32
|
+
LinkTarget,
|
|
33
|
+
ListKind,
|
|
34
|
+
Paragraph,
|
|
35
|
+
ParagraphProps,
|
|
36
|
+
ParagraphRole,
|
|
37
|
+
RunStyle,
|
|
38
|
+
Section,
|
|
39
|
+
Side,
|
|
40
|
+
Sides,
|
|
41
|
+
Table,
|
|
42
|
+
TableCell,
|
|
43
|
+
TableRow,
|
|
44
|
+
TabStop,
|
|
45
|
+
} from './model';
|
|
46
|
+
import { capitalize, ParagraphBuilder } from './paragraph';
|
|
47
|
+
import { paragraphsOf } from './styles';
|
|
48
|
+
import { eighths, emu, halfPoints, points, px, twips } from './units';
|
|
49
|
+
|
|
50
|
+
const MEDIA = new Set(['img', 'svg', 'canvas']);
|
|
51
|
+
const MEDIA_SELECTOR = 'img, svg, canvas';
|
|
52
|
+
const SKIP = new Set([
|
|
53
|
+
'script',
|
|
54
|
+
'style',
|
|
55
|
+
'template',
|
|
56
|
+
'noscript',
|
|
57
|
+
'input',
|
|
58
|
+
'button',
|
|
59
|
+
'select',
|
|
60
|
+
'textarea',
|
|
61
|
+
'video',
|
|
62
|
+
'audio',
|
|
63
|
+
'iframe',
|
|
64
|
+
'object',
|
|
65
|
+
'embed',
|
|
66
|
+
'dialog',
|
|
67
|
+
]);
|
|
68
|
+
/** Every heading Word has a style for; the outline stops at h3. */
|
|
69
|
+
const ANY_HEADING = `${HEADING_SELECTOR}, h4, h5, h6`;
|
|
70
|
+
/** What makes a figure's body text worth keeping as text rather than a picture. */
|
|
71
|
+
const STRUCTURAL = 'table, p, pre, blockquote, ul, ol, dl, h1, h2, h3, h4, h5, h6';
|
|
72
|
+
const FOOTNOTE_MARKER = `sup[${LABEL_ATTR}="footnote"]`;
|
|
73
|
+
|
|
74
|
+
type SourceBase = {
|
|
75
|
+
host: HTMLElement;
|
|
76
|
+
/** 1-based: the sheet, or a flow section's first sheet. */
|
|
77
|
+
page: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One sheet, or one flow section laid out as a column. A running header or
|
|
82
|
+
* footer is read from a `sentinel` copy, drawn with sentinel page numbers; a
|
|
83
|
+
* flow section's footer is also drawn as its first sheet and, when it has one,
|
|
84
|
+
* its next, which the sentinel copy is checked against.
|
|
85
|
+
*/
|
|
86
|
+
export type ExtractSource =
|
|
87
|
+
| (SourceBase & { kind: 'fixed'; sentinel: HTMLElement })
|
|
88
|
+
| (SourceBase & {
|
|
89
|
+
kind: 'flow';
|
|
90
|
+
footer?: { sentinel: HTMLElement; first: HTMLElement; next?: HTMLElement };
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export type ExtractOptions = {
|
|
94
|
+
sources: ExtractSource[];
|
|
95
|
+
/** Twips. */
|
|
96
|
+
sheet: { width: number; height: number };
|
|
97
|
+
/** CSS px, for a page whose root sets no padding of its own. */
|
|
98
|
+
margin: number;
|
|
99
|
+
/** The numbers the sentinel copies were drawn with, standing in for PAGE and NUMPAGES. */
|
|
100
|
+
sentinels: { page: number; count: number };
|
|
101
|
+
pageCount: number;
|
|
102
|
+
rasterize: (el: Element) => Promise<Uint8Array | null>;
|
|
103
|
+
/** After each source; awaited, so the caller can report progress and yield. */
|
|
104
|
+
onSection?: (done: number, total: number) => Promise<void> | void;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type Extracted = Pick<
|
|
108
|
+
DocxModel,
|
|
109
|
+
'sections' | 'footnotes' | 'lists' | 'media' | 'background' | 'fonts'
|
|
110
|
+
>;
|
|
111
|
+
|
|
112
|
+
type Column = { left: number; right: number; top: number };
|
|
113
|
+
|
|
114
|
+
type Band = 'header' | 'footer';
|
|
115
|
+
|
|
116
|
+
type BandCopy = { parts: Element[]; host: HTMLElement };
|
|
117
|
+
|
|
118
|
+
type InlineState = {
|
|
119
|
+
underline: boolean;
|
|
120
|
+
strike: boolean;
|
|
121
|
+
shading?: string;
|
|
122
|
+
vertAlign?: 'superscript' | 'subscript';
|
|
123
|
+
/** Half-points of the text a super/subscript sits in — Word shrinks it itself. */
|
|
124
|
+
baseSize?: number;
|
|
125
|
+
link?: LinkTarget;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
type Box = { borders: Borders; shading?: string };
|
|
129
|
+
|
|
130
|
+
type Ctx = {
|
|
131
|
+
flow: Flow;
|
|
132
|
+
role: ParagraphRole;
|
|
133
|
+
inline: InlineState;
|
|
134
|
+
box?: Box;
|
|
135
|
+
/** Shared by reference: the first paragraph of a list item takes the number. */
|
|
136
|
+
list?: { num: number; level: number; pending: boolean };
|
|
137
|
+
positioned: boolean;
|
|
138
|
+
/** Sentinel page numbers in this text become PAGE / NUMPAGES fields. */
|
|
139
|
+
fields: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* A fixed page's text block. Anything positioned wholly above or below it is
|
|
142
|
+
* the page's own header or footer, read separately.
|
|
143
|
+
*/
|
|
144
|
+
bands?: { top: number; bottom: number };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const PLAIN: InlineState = { underline: false, strike: false };
|
|
148
|
+
|
|
149
|
+
function isPlaced(cs: CSSStyleDeclaration): boolean {
|
|
150
|
+
return cs.position === 'absolute' || cs.position === 'fixed';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function centred(left: number, right: number): boolean {
|
|
154
|
+
return left > 2 && Math.abs(left - right) < 2;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const SIDES: readonly Side[] = ['top', 'right', 'bottom', 'left'];
|
|
158
|
+
|
|
159
|
+
function sides(value: (side: Side) => number): Sides {
|
|
160
|
+
return { top: value('top'), right: value('right'), bottom: value('bottom'), left: value('left') };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function applyFrame(props: ParagraphProps, frame: Box | undefined): void {
|
|
164
|
+
if (frame && Object.keys(frame.borders).length > 0) props.borders = frame.borders;
|
|
165
|
+
if (frame?.shading) props.shading = frame.shading;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function keepWithNext(block: Block): void {
|
|
169
|
+
if (block.type === 'paragraph') {
|
|
170
|
+
block.props.keepNext = true;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// A table stays with the next paragraph through its last row's paragraphs.
|
|
174
|
+
for (const cell of block.rows[block.rows.length - 1]?.cells ?? []) {
|
|
175
|
+
for (const inner of cell.blocks) if (inner.type === 'paragraph') inner.props.keepNext = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function firstParagraphIn(blocks: Block[]): Paragraph | undefined {
|
|
180
|
+
for (const [paragraph] of paragraphsOf(blocks)) return paragraph;
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type Spacing = 'spaceBefore' | 'spaceAfter';
|
|
185
|
+
|
|
186
|
+
/** A column of blocks, with the vertical gaps between them read off the page. */
|
|
187
|
+
class Flow {
|
|
188
|
+
readonly blocks: Block[] = [];
|
|
189
|
+
top = Number.POSITIVE_INFINITY;
|
|
190
|
+
bottom = Number.NEGATIVE_INFINITY;
|
|
191
|
+
private cursor: number;
|
|
192
|
+
/** The widest gap, where the spacing that holds it lives, and whether content sits above it. */
|
|
193
|
+
private widest?: {
|
|
194
|
+
gap: number;
|
|
195
|
+
props: ParagraphProps;
|
|
196
|
+
side: Spacing;
|
|
197
|
+
base: number;
|
|
198
|
+
inside: boolean;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
constructor(readonly column: Column) {
|
|
202
|
+
this.cursor = column.top;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
add(block: Block, rect: DOMRect, positioned: boolean): void {
|
|
206
|
+
if (!positioned) {
|
|
207
|
+
const gap = Math.max(0, rect.top - this.cursor);
|
|
208
|
+
if (block.type === 'paragraph') {
|
|
209
|
+
this.space(block.props, 'spaceBefore', gap);
|
|
210
|
+
} else {
|
|
211
|
+
// A table has no space of its own; the paragraph above carries it.
|
|
212
|
+
const previous = this.blocks[this.blocks.length - 1];
|
|
213
|
+
if (previous?.type === 'paragraph' && gap > 0)
|
|
214
|
+
this.space(previous.props, 'spaceAfter', gap);
|
|
215
|
+
}
|
|
216
|
+
this.cursor = Math.max(this.cursor, rect.bottom);
|
|
217
|
+
this.occupy(rect);
|
|
218
|
+
}
|
|
219
|
+
this.blocks.push(block);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Room on the page taken without becoming a block — the printed notes. */
|
|
223
|
+
occupy(rect: DOMRect): void {
|
|
224
|
+
this.top = Math.min(this.top, rect.top);
|
|
225
|
+
this.bottom = Math.max(this.bottom, rect.bottom);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A fixed page is one sheet, and has to stay one in Word, which sets some
|
|
230
|
+
* lines a little taller than the browser did. So the page's widest gap — the
|
|
231
|
+
* space that lowers a cover to the foot of its sheet — gives up some slack,
|
|
232
|
+
* rather than the last line spilling onto a sheet of its own. The section's
|
|
233
|
+
* vertical alignment would lower a cover more directly, but Word left a cover
|
|
234
|
+
* carrying a footnote at the top of the sheet under it.
|
|
235
|
+
*/
|
|
236
|
+
fitSheet(): void {
|
|
237
|
+
const widest = this.widest;
|
|
238
|
+
if (!widest || widest.gap < 72) return;
|
|
239
|
+
const content = this.bottom - this.top - (widest.inside ? widest.gap : 0);
|
|
240
|
+
const slack = 24 + content * 0.15;
|
|
241
|
+
widest.props[widest.side] = widest.base + twips(Math.max(0, widest.gap - slack));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private space(props: ParagraphProps, side: Spacing, gap: number): void {
|
|
245
|
+
const base = props[side] ?? 0;
|
|
246
|
+
props[side] = base + twips(gap);
|
|
247
|
+
if (gap > (this.widest?.gap ?? 0)) {
|
|
248
|
+
this.widest = { gap, props, side, base, inside: Number.isFinite(this.top) };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
class Colors {
|
|
254
|
+
private readonly cache = new Map<string, [number, number, number, number]>();
|
|
255
|
+
private probe: CanvasRenderingContext2D | null = null;
|
|
256
|
+
private paper: [number, number, number] = [255, 255, 255];
|
|
257
|
+
|
|
258
|
+
/** What a translucent colour is seen against — the document's own paper, not white. */
|
|
259
|
+
setPaper(hex: string): void {
|
|
260
|
+
this.paper = [0, 2, 4].map((at) => Number.parseInt(hex.slice(at, at + 2), 16)) as [
|
|
261
|
+
number,
|
|
262
|
+
number,
|
|
263
|
+
number,
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private rgba(css: string): [number, number, number, number] {
|
|
268
|
+
const known = this.cache.get(css);
|
|
269
|
+
if (known) return known;
|
|
270
|
+
let value: [number, number, number, number] = [0, 0, 0, 0];
|
|
271
|
+
const match =
|
|
272
|
+
/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+)(%?))?\s*\)$/.exec(css);
|
|
273
|
+
if (match) {
|
|
274
|
+
const alpha = match[4] === undefined ? 1 : Number(match[4]) / (match[5] ? 100 : 1);
|
|
275
|
+
value = [Number(match[1]), Number(match[2]), Number(match[3]), alpha];
|
|
276
|
+
} else if (css !== 'transparent') {
|
|
277
|
+
// oklch(), lab(), color(display-p3 …): let the canvas map it into sRGB.
|
|
278
|
+
this.probe ??= document
|
|
279
|
+
.createElement('canvas')
|
|
280
|
+
.getContext('2d', { willReadFrequently: true });
|
|
281
|
+
if (this.probe) {
|
|
282
|
+
this.probe.clearRect(0, 0, 1, 1);
|
|
283
|
+
this.probe.fillStyle = 'rgba(0, 0, 0, 0)';
|
|
284
|
+
this.probe.fillStyle = css;
|
|
285
|
+
this.probe.fillRect(0, 0, 1, 1);
|
|
286
|
+
const [r = 0, g = 0, b = 0, a = 0] = this.probe.getImageData(0, 0, 1, 1).data;
|
|
287
|
+
value = [r, g, b, a / 255];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
this.cache.set(css, value);
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Six-digit hex over the paper; undefined when the colour is (nearly) transparent. */
|
|
295
|
+
hex(css: string): string | undefined {
|
|
296
|
+
const [r, g, b, a] = this.rgba(css);
|
|
297
|
+
if (a < 0.05) return undefined;
|
|
298
|
+
const channel = (c: number, under: number) =>
|
|
299
|
+
Math.round(c * a + under * (1 - a))
|
|
300
|
+
.toString(16)
|
|
301
|
+
.padStart(2, '0');
|
|
302
|
+
const [pr, pg, pb] = this.paper;
|
|
303
|
+
return `${channel(r, pr)}${channel(g, pg)}${channel(b, pb)}`.toUpperCase();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function* childNodes(parent: Node, style: (el: Element) => CSSStyleDeclaration): Generator<Node> {
|
|
308
|
+
for (const node of Array.from(parent.childNodes)) {
|
|
309
|
+
if (node instanceof Element && style(node).display === 'contents') {
|
|
310
|
+
yield* childNodes(node, style);
|
|
311
|
+
} else {
|
|
312
|
+
yield node;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The line boxes loose text occupies. A range measures glyphs, which sit
|
|
319
|
+
* inside the line with half the leading above and half below; measured bare,
|
|
320
|
+
* every gap to a neighbour would come out that much too large.
|
|
321
|
+
*/
|
|
322
|
+
function rangeRect(first: Node, last: Node, lineHeight: number): DOMRect | null {
|
|
323
|
+
const range = document.createRange();
|
|
324
|
+
range.setStartBefore(first);
|
|
325
|
+
range.setEndAfter(last);
|
|
326
|
+
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
|
|
327
|
+
const glyphs = rects[0];
|
|
328
|
+
if (!glyphs) return null;
|
|
329
|
+
const leading = Math.max(0, (lineHeight - glyphs.height) / 2);
|
|
330
|
+
const top = Math.min(...rects.map((r) => r.top)) - leading;
|
|
331
|
+
const bottom = Math.max(...rects.map((r) => r.bottom)) + leading;
|
|
332
|
+
const left = Math.min(...rects.map((r) => r.left));
|
|
333
|
+
const right = Math.max(...rects.map((r) => r.right));
|
|
334
|
+
return new DOMRect(left, top, right - left, bottom - top);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Text outside any drawing — an SVG's labels do not make it prose. */
|
|
338
|
+
function hasProse(el: Element): boolean {
|
|
339
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
|
|
340
|
+
acceptNode: (node) =>
|
|
341
|
+
node instanceof Element && node.localName === 'svg'
|
|
342
|
+
? NodeFilter.FILTER_REJECT
|
|
343
|
+
: NodeFilter.FILTER_ACCEPT,
|
|
344
|
+
});
|
|
345
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
346
|
+
if (node.nodeType === Node.TEXT_NODE && node.nodeValue?.trim()) return true;
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function listKindOf(type: string, ordered: boolean): ListKind | null {
|
|
352
|
+
switch (type) {
|
|
353
|
+
case 'none':
|
|
354
|
+
return null;
|
|
355
|
+
case 'disc':
|
|
356
|
+
case 'circle':
|
|
357
|
+
case 'square':
|
|
358
|
+
return type;
|
|
359
|
+
case 'decimal':
|
|
360
|
+
case 'decimal-leading-zero':
|
|
361
|
+
case 'cjk-decimal':
|
|
362
|
+
return 'decimal';
|
|
363
|
+
case 'lower-alpha':
|
|
364
|
+
case 'lower-latin':
|
|
365
|
+
return 'lowerLetter';
|
|
366
|
+
case 'upper-alpha':
|
|
367
|
+
case 'upper-latin':
|
|
368
|
+
return 'upperLetter';
|
|
369
|
+
case 'lower-roman':
|
|
370
|
+
return 'lowerRoman';
|
|
371
|
+
case 'upper-roman':
|
|
372
|
+
return 'upperRoman';
|
|
373
|
+
default:
|
|
374
|
+
return ordered ? 'decimal' : 'disc';
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function alignOf(textAlign: string, direction: string): ParagraphProps['align'] {
|
|
379
|
+
switch (textAlign) {
|
|
380
|
+
case 'center':
|
|
381
|
+
case '-webkit-center':
|
|
382
|
+
return 'center';
|
|
383
|
+
case 'right':
|
|
384
|
+
case '-webkit-right':
|
|
385
|
+
return 'right';
|
|
386
|
+
case 'end':
|
|
387
|
+
return direction === 'rtl' ? undefined : 'right';
|
|
388
|
+
case 'justify':
|
|
389
|
+
return 'both';
|
|
390
|
+
default:
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function leaderOf(cs: CSSStyleDeclaration): TabStop['leader'] {
|
|
396
|
+
if (px(cs.borderBottomWidth) <= 0) return undefined;
|
|
397
|
+
switch (cs.borderBottomStyle) {
|
|
398
|
+
case 'dotted':
|
|
399
|
+
return 'dot';
|
|
400
|
+
case 'dashed':
|
|
401
|
+
return 'hyphen';
|
|
402
|
+
case 'solid':
|
|
403
|
+
return 'underscore';
|
|
404
|
+
default:
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function textOf(parts: Element[]): string {
|
|
410
|
+
return parts
|
|
411
|
+
.map((part) => part.textContent ?? '')
|
|
412
|
+
.join(' ')
|
|
413
|
+
.replace(/\s+/g, ' ')
|
|
414
|
+
.trim();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function mergeBoxes(outer: Box | undefined, inner: Box | undefined): Box | undefined {
|
|
418
|
+
if (!outer || !inner) return outer ?? inner;
|
|
419
|
+
return {
|
|
420
|
+
borders: { ...outer.borders, ...inner.borders },
|
|
421
|
+
shading: inner.shading ?? outer.shading,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function extractDocument(options: ExtractOptions): Promise<Extracted> {
|
|
426
|
+
return new Extractor(options).run();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
class Extractor {
|
|
430
|
+
private readonly computed = new WeakMap<Element, CSSStyleDeclaration>();
|
|
431
|
+
private readonly rects = new WeakMap<Element, DOMRect>();
|
|
432
|
+
private readonly runs = new WeakMap<Element, WeakMap<InlineState, RunStyle>>();
|
|
433
|
+
private readonly fontSets = new Map<string, FontSet>();
|
|
434
|
+
private readonly declarations = new Map<string, FontDeclaration>();
|
|
435
|
+
private readonly colors = new Colors();
|
|
436
|
+
private readonly bookmarks = new Map<string, string>();
|
|
437
|
+
private readonly bookmarkNames = new Set<string>();
|
|
438
|
+
/** Printed notes by id, each taken out as its marker claims it. */
|
|
439
|
+
private readonly noteBodies = new Map<string, Element>();
|
|
440
|
+
private readonly footnotes: DocxModel['footnotes'] = [];
|
|
441
|
+
private readonly lists: DocxModel['lists'] = [];
|
|
442
|
+
private readonly media: MediaStore;
|
|
443
|
+
private readonly sentinel: RegExp;
|
|
444
|
+
private paper = 'FFFFFF';
|
|
445
|
+
|
|
446
|
+
constructor(private readonly options: ExtractOptions) {
|
|
447
|
+
this.media = new MediaStore(options.rasterize);
|
|
448
|
+
this.sentinel = new RegExp(`(${options.sentinels.page}|${options.sentinels.count})`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private readonly style = (el: Element): CSSStyleDeclaration => {
|
|
452
|
+
let cs = this.computed.get(el);
|
|
453
|
+
if (!cs) {
|
|
454
|
+
cs = getComputedStyle(el);
|
|
455
|
+
this.computed.set(el, cs);
|
|
456
|
+
}
|
|
457
|
+
return cs;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
private rect(el: Element): DOMRect {
|
|
461
|
+
let rect = this.rects.get(el);
|
|
462
|
+
if (!rect) {
|
|
463
|
+
rect = el.getBoundingClientRect();
|
|
464
|
+
this.rects.set(el, rect);
|
|
465
|
+
}
|
|
466
|
+
return rect;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Inside the border and padding. */
|
|
470
|
+
private contentBox(el: Element): Column {
|
|
471
|
+
const rect = this.rect(el);
|
|
472
|
+
const cs = this.style(el);
|
|
473
|
+
return {
|
|
474
|
+
left: rect.left + px(cs.borderLeftWidth) + px(cs.paddingLeft),
|
|
475
|
+
right: rect.right - px(cs.borderRightWidth) - px(cs.paddingRight),
|
|
476
|
+
top: rect.top + px(cs.borderTopWidth) + px(cs.paddingTop),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private root(flow: Flow, role: ParagraphRole, extra: Partial<Ctx> = {}): Ctx {
|
|
481
|
+
return { flow, role, inline: PLAIN, positioned: false, fields: false, ...extra };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async run(): Promise<Extracted> {
|
|
485
|
+
const first = this.options.sources[0]?.host;
|
|
486
|
+
if (first) this.paper = this.colors.hex(this.style(first).backgroundColor) ?? 'FFFFFF';
|
|
487
|
+
this.colors.setPaper(this.paper);
|
|
488
|
+
|
|
489
|
+
for (const { host } of this.options.sources) {
|
|
490
|
+
for (const row of Array.from(host.querySelectorAll(`[${FOOTNOTE_ROW_ATTR}]`))) {
|
|
491
|
+
const id = row.getAttribute(FOOTNOTE_ROW_ATTR);
|
|
492
|
+
const body = row.querySelector(`[${FOOTNOTE_BODY_ATTR}]`);
|
|
493
|
+
if (id && body && !this.noteBodies.has(id)) this.noteBodies.set(id, body);
|
|
494
|
+
}
|
|
495
|
+
for (const heading of Array.from(host.querySelectorAll(ANY_HEADING))) {
|
|
496
|
+
if (heading.id) this.bookmark(heading.id);
|
|
497
|
+
}
|
|
498
|
+
for (const labelled of Array.from(host.querySelectorAll(`[${LABEL_ID_ATTR}]`))) {
|
|
499
|
+
const id = labelled.getAttribute(LABEL_ID_ATTR);
|
|
500
|
+
if (id && labelled.getAttribute(LABEL_ATTR) !== 'footnote') this.bookmark(id);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const { sources, onSection } = this.options;
|
|
505
|
+
const sections: Section[] = [];
|
|
506
|
+
for (const source of sources) {
|
|
507
|
+
sections.push(await this.section(source));
|
|
508
|
+
await onSection?.(sections.length, sources.length);
|
|
509
|
+
}
|
|
510
|
+
this.pruneAnchors(sections);
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
sections,
|
|
514
|
+
footnotes: this.footnotes,
|
|
515
|
+
lists: this.lists,
|
|
516
|
+
media: this.media.media,
|
|
517
|
+
fonts: [...this.declarations.values()],
|
|
518
|
+
background: this.paper === 'FFFFFF' ? undefined : this.paper,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Links and page references to a bookmark no paragraph ended up carrying — a
|
|
524
|
+
* figure that yielded none of its own — would read "Error! Bookmark not
|
|
525
|
+
* defined." in Word once fields update, so they go back to plain text.
|
|
526
|
+
*/
|
|
527
|
+
private pruneAnchors(sections: Section[]): void {
|
|
528
|
+
const paragraphs: Paragraph[] = [];
|
|
529
|
+
const collect = (blocks: Block[] = []) => {
|
|
530
|
+
for (const [paragraph] of paragraphsOf(blocks)) paragraphs.push(paragraph);
|
|
531
|
+
};
|
|
532
|
+
for (const { blocks, header, footer, footerFirst } of sections) {
|
|
533
|
+
collect(blocks);
|
|
534
|
+
collect(header);
|
|
535
|
+
collect(footer);
|
|
536
|
+
collect(footerFirst);
|
|
537
|
+
}
|
|
538
|
+
for (const note of this.footnotes) collect(note.blocks);
|
|
539
|
+
const placed = new Set(paragraphs.map((paragraph) => paragraph.bookmark));
|
|
540
|
+
for (const paragraph of paragraphs) {
|
|
541
|
+
paragraph.inlines = paragraph.inlines.map((inline): Inline => {
|
|
542
|
+
if (inline.type === 'field' && inline.instr.startsWith('PAGEREF ')) {
|
|
543
|
+
const anchor = inline.instr.split(' ')[1];
|
|
544
|
+
if (!placed.has(anchor))
|
|
545
|
+
return { type: 'text', text: inline.cached, style: inline.style };
|
|
546
|
+
}
|
|
547
|
+
if (
|
|
548
|
+
'link' in inline &&
|
|
549
|
+
inline.link &&
|
|
550
|
+
'anchor' in inline.link &&
|
|
551
|
+
!placed.has(inline.link.anchor)
|
|
552
|
+
) {
|
|
553
|
+
return { ...inline, link: undefined };
|
|
554
|
+
}
|
|
555
|
+
return inline;
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Word bookmark names: 40 characters, letters, digits and underscores. */
|
|
561
|
+
private bookmark(id: string): void {
|
|
562
|
+
if (this.bookmarks.has(id)) return;
|
|
563
|
+
const base = `_od_${id.replace(/[^A-Za-z0-9_]/g, '_')}`.slice(0, 36);
|
|
564
|
+
let name = base;
|
|
565
|
+
for (let n = 2; this.bookmarkNames.has(name); n++) name = `${base.slice(0, 33)}_${n}`;
|
|
566
|
+
this.bookmarkNames.add(name);
|
|
567
|
+
this.bookmarks.set(id, name);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private async section(source: ExtractSource): Promise<Section> {
|
|
571
|
+
const { host } = source;
|
|
572
|
+
const sheet = this.rect(host);
|
|
573
|
+
const elements = Array.from(host.children);
|
|
574
|
+
const pageRoot = elements.length === 1 ? elements[0] : null;
|
|
575
|
+
const margin = this.marginsOf(pageRoot, sheet, source.kind === 'flow');
|
|
576
|
+
|
|
577
|
+
const flow = new Flow({
|
|
578
|
+
left: sheet.left + margin.left,
|
|
579
|
+
right: sheet.right - margin.right,
|
|
580
|
+
top: sheet.top + margin.top,
|
|
581
|
+
});
|
|
582
|
+
const bands =
|
|
583
|
+
source.kind === 'fixed'
|
|
584
|
+
? { top: flow.column.top, bottom: sheet.bottom - margin.bottom }
|
|
585
|
+
: undefined;
|
|
586
|
+
// The page's own fill and frame: a dark cover is shading behind its text in
|
|
587
|
+
// Word, not white text on white paper.
|
|
588
|
+
const box = pageRoot ? this.boxOf(this.style(pageRoot)) : undefined;
|
|
589
|
+
await this.walkChildren(pageRoot ?? host, this.root(flow, 'body', { bands, box }));
|
|
590
|
+
|
|
591
|
+
const section: Section = {
|
|
592
|
+
blocks: flow.blocks,
|
|
593
|
+
page: {
|
|
594
|
+
...this.options.sheet,
|
|
595
|
+
margin: sides((side) => twips(margin[side])),
|
|
596
|
+
header: twips(margin.top / 2),
|
|
597
|
+
footer: twips(margin.bottom / 2),
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
if (source.kind === 'fixed') {
|
|
602
|
+
flow.fitSheet();
|
|
603
|
+
const real = this.bandParts(host, margin);
|
|
604
|
+
if (real.header.length === 0 && real.footer.length === 0) return section;
|
|
605
|
+
const sentinel = this.bandParts(source.sentinel, margin);
|
|
606
|
+
for (const which of ['header', 'footer'] as const) {
|
|
607
|
+
// Only a band the page really has: a footer written as
|
|
608
|
+
// `n === 1 ? null : …` must not appear on the cover.
|
|
609
|
+
if (real[which].length === 0) continue;
|
|
610
|
+
await this.band(section, which, margin, source.page, {
|
|
611
|
+
sentinel: { parts: sentinel[which], host: source.sentinel },
|
|
612
|
+
real: { parts: real[which], host },
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
} else if (source.footer) {
|
|
616
|
+
const { footer } = source;
|
|
617
|
+
const copy = (probe: HTMLElement) => ({
|
|
618
|
+
parts: Array.from(probe.querySelector(`[${FLOW_FOOTER_ATTR}]`)?.children ?? []),
|
|
619
|
+
host: probe,
|
|
620
|
+
});
|
|
621
|
+
const sentinel = copy(footer.sentinel);
|
|
622
|
+
const first = copy(footer.first);
|
|
623
|
+
const next = footer.next ? copy(footer.next) : undefined;
|
|
624
|
+
// The running footer is judged on the second sheet, where one hidden on
|
|
625
|
+
// the opening page shows.
|
|
626
|
+
const live = await this.band(section, 'footer', margin, source.page + (next ? 1 : 0), {
|
|
627
|
+
sentinel,
|
|
628
|
+
real: next ?? first,
|
|
629
|
+
});
|
|
630
|
+
if (next) {
|
|
631
|
+
const running = live ? this.expected(sentinel.parts, source.page) : textOf(next.parts);
|
|
632
|
+
if (running !== textOf(first.parts)) {
|
|
633
|
+
const opening = await this.bandParagraphs('footer', first, margin, false);
|
|
634
|
+
section.footerFirst = opening?.paragraphs ?? [];
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return section;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The page component's own padding is the text block. A page that sets none
|
|
643
|
+
* at all leaves it to the design's margin; one that sets some keeps its zeros
|
|
644
|
+
* — a band that runs to one edge. A flow section's shell always sets its own.
|
|
645
|
+
*/
|
|
646
|
+
private marginsOf(pageRoot: Element | null, sheet: DOMRect, explicit: boolean): Sides {
|
|
647
|
+
const fallback = sides(() => this.options.margin);
|
|
648
|
+
if (!pageRoot || Math.abs(this.rect(pageRoot).width - sheet.width) > 2) return fallback;
|
|
649
|
+
const cs = this.style(pageRoot);
|
|
650
|
+
const own = sides(
|
|
651
|
+
(side) =>
|
|
652
|
+
px(cs.getPropertyValue(`padding-${side}`)) +
|
|
653
|
+
px(cs.getPropertyValue(`border-${side}-width`)),
|
|
654
|
+
);
|
|
655
|
+
return explicit || SIDES.some((side) => own[side] > 0) ? own : fallback;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Positioned, with something to show, and wholly inside a margin band. */
|
|
659
|
+
private bandOf(el: Element, cs: CSSStyleDeclaration, bands: Ctx['bands']): Band | null {
|
|
660
|
+
if (!bands || !isPlaced(cs)) return null;
|
|
661
|
+
const rect = this.rect(el);
|
|
662
|
+
const band =
|
|
663
|
+
rect.top >= bands.bottom - 1 ? 'footer' : rect.bottom <= bands.top + 1 ? 'header' : null;
|
|
664
|
+
if (!band || rect.height <= 0) return null;
|
|
665
|
+
return MEDIA.has(el.localName) || el.querySelector(MEDIA_SELECTOR) || hasProse(el)
|
|
666
|
+
? band
|
|
667
|
+
: null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private bandParts(host: HTMLElement, margin: Sides): Record<Band, Element[]> {
|
|
671
|
+
const sheet = this.rect(host);
|
|
672
|
+
const bands = { top: sheet.top + margin.top, bottom: sheet.bottom - margin.bottom };
|
|
673
|
+
const parts: Record<Band, Element[]> = { header: [], footer: [] };
|
|
674
|
+
const visit = (parent: Element) => {
|
|
675
|
+
for (const child of Array.from(parent.children)) {
|
|
676
|
+
const cs = this.style(child);
|
|
677
|
+
if (this.skipped(child, cs)) continue;
|
|
678
|
+
const band = this.bandOf(child, cs, bands);
|
|
679
|
+
if (band) parts[band].push(child);
|
|
680
|
+
else if (!MEDIA.has(child.localName)) visit(child);
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
visit(host);
|
|
684
|
+
return parts;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* A running header or footer, read from the copy drawn with sentinel page
|
|
689
|
+
* numbers so those become fields. When putting the real numbers back does
|
|
690
|
+
* not give the real copy's text — `n - 1`, `toLocaleString()`, roman
|
|
691
|
+
* numerals — they cannot be fields, and the real text goes in as it is.
|
|
692
|
+
*/
|
|
693
|
+
private async band(
|
|
694
|
+
section: Section,
|
|
695
|
+
which: Band,
|
|
696
|
+
margin: Sides,
|
|
697
|
+
page: number,
|
|
698
|
+
copies: Record<'sentinel' | 'real', BandCopy>,
|
|
699
|
+
): Promise<boolean> {
|
|
700
|
+
const live = this.expected(copies.sentinel.parts, page) === textOf(copies.real.parts);
|
|
701
|
+
const read = await this.bandParagraphs(
|
|
702
|
+
which,
|
|
703
|
+
live ? copies.sentinel : copies.real,
|
|
704
|
+
margin,
|
|
705
|
+
live,
|
|
706
|
+
);
|
|
707
|
+
if (read) {
|
|
708
|
+
section[which] = read.paragraphs;
|
|
709
|
+
section.page[which] = read.distance;
|
|
710
|
+
}
|
|
711
|
+
return live;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** What the sentinel copy says on `page`, with the real numbers put back. */
|
|
715
|
+
private expected(parts: Element[], page: number): string {
|
|
716
|
+
const { sentinels, pageCount } = this.options;
|
|
717
|
+
return textOf(parts)
|
|
718
|
+
.split(String(sentinels.page))
|
|
719
|
+
.join(String(page))
|
|
720
|
+
.split(String(sentinels.count))
|
|
721
|
+
.join(String(pageCount));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private async bandParagraphs(
|
|
725
|
+
which: Band,
|
|
726
|
+
{ parts, host }: BandCopy,
|
|
727
|
+
margin: Sides,
|
|
728
|
+
fields: boolean,
|
|
729
|
+
): Promise<{ paragraphs: Paragraph[]; distance: number } | undefined> {
|
|
730
|
+
if (parts.length === 0) return undefined;
|
|
731
|
+
const sheet = this.rect(host);
|
|
732
|
+
const flow = new Flow({
|
|
733
|
+
left: sheet.left + margin.left,
|
|
734
|
+
right: sheet.right - margin.right,
|
|
735
|
+
top: sheet.top,
|
|
736
|
+
});
|
|
737
|
+
await this.walkNodes(parts, host, this.root(flow, which, { positioned: true, fields }));
|
|
738
|
+
const paragraphs = flow.blocks.filter(
|
|
739
|
+
(block): block is Paragraph => block.type === 'paragraph',
|
|
740
|
+
);
|
|
741
|
+
if (paragraphs.length === 0) return undefined;
|
|
742
|
+
|
|
743
|
+
const rects = parts.map((part) => this.rect(part));
|
|
744
|
+
const distance =
|
|
745
|
+
which === 'footer'
|
|
746
|
+
? sheet.bottom - Math.max(...rects.map((rect) => rect.bottom))
|
|
747
|
+
: Math.min(...rects.map((rect) => rect.top)) - sheet.top;
|
|
748
|
+
const edge = which === 'footer' ? margin.bottom : margin.top;
|
|
749
|
+
return { paragraphs, distance: twips(Math.max(0, Math.min(distance, edge))) };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private skipped(el: Element, cs: CSSStyleDeclaration): boolean {
|
|
753
|
+
return (
|
|
754
|
+
SKIP.has(el.localName) ||
|
|
755
|
+
cs.display === 'none' ||
|
|
756
|
+
cs.visibility === 'hidden' ||
|
|
757
|
+
cs.visibility === 'collapse' ||
|
|
758
|
+
// Printed notes are rebuilt as Word footnotes from their markers.
|
|
759
|
+
el.hasAttribute(FOOTNOTES_ATTR)
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private inlineLevel(el: Element, cs: CSSStyleDeclaration): boolean {
|
|
764
|
+
return el.localName === 'br' || cs.display.startsWith('inline') || cs.display === 'ruby';
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private walkChildren(parent: Element, ctx: Ctx): Promise<void> {
|
|
768
|
+
return this.walkNodes(childNodes(parent, this.style), parent, ctx);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Blocks become paragraphs; loose inline content between them becomes one of its own. */
|
|
772
|
+
private async walkNodes(nodes: Iterable<Node>, parent: Element, ctx: Ctx): Promise<void> {
|
|
773
|
+
let builder: ParagraphBuilder | null = null;
|
|
774
|
+
let first: Node | null = null;
|
|
775
|
+
let last: Node | null = null;
|
|
776
|
+
const flush = () => {
|
|
777
|
+
if (builder && !builder.empty && first && last) {
|
|
778
|
+
const lineHeight = px(this.style(parent).lineHeight);
|
|
779
|
+
const rect = rangeRect(first, last, lineHeight) ?? this.rect(parent);
|
|
780
|
+
this.emit(builder, parent, rect, ctx, false);
|
|
781
|
+
}
|
|
782
|
+
builder = null;
|
|
783
|
+
first = null;
|
|
784
|
+
last = null;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
for (const node of nodes) {
|
|
788
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
789
|
+
if (!node.nodeValue) continue;
|
|
790
|
+
builder ??= new ParagraphBuilder();
|
|
791
|
+
first ??= node;
|
|
792
|
+
last = node;
|
|
793
|
+
this.text(node as Text, builder, ctx.inline, ctx);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (!(node instanceof Element)) continue;
|
|
797
|
+
const cs = this.style(node);
|
|
798
|
+
if (this.skipped(node, cs)) {
|
|
799
|
+
if (node.hasAttribute(FOOTNOTES_ATTR)) ctx.flow.occupy(this.rect(node));
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (this.inlineLevel(node, cs)) {
|
|
803
|
+
builder ??= new ParagraphBuilder();
|
|
804
|
+
first ??= node;
|
|
805
|
+
last = node;
|
|
806
|
+
await this.inline(node, builder, ctx.inline, ctx);
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
flush();
|
|
810
|
+
await this.block(node, cs, ctx);
|
|
811
|
+
}
|
|
812
|
+
flush();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private async block(el: Element, cs: CSSStyleDeclaration, outer: Ctx): Promise<void> {
|
|
816
|
+
if (this.bandOf(el, cs, outer.bands)) return;
|
|
817
|
+
const blocks = outer.flow.blocks;
|
|
818
|
+
const start = blocks.length;
|
|
819
|
+
await this.blockContent(el, cs, outer);
|
|
820
|
+
if (blocks.length === start) return;
|
|
821
|
+
// The page-break hints the packer honours, as Word's own.
|
|
822
|
+
const hints = blockHints(el);
|
|
823
|
+
const first = blocks[start];
|
|
824
|
+
if (hints.breakBefore && first.type === 'paragraph') first.props.pageBreakBefore = true;
|
|
825
|
+
if (hints.keepWithNext) keepWithNext(blocks[blocks.length - 1]);
|
|
826
|
+
if (hints.keepWithPrevious && start > 0) keepWithNext(blocks[start - 1]);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private async blockContent(el: Element, cs: CSSStyleDeclaration, outer: Ctx): Promise<void> {
|
|
830
|
+
const ctx: Ctx = {
|
|
831
|
+
...outer,
|
|
832
|
+
positioned: outer.positioned || isPlaced(cs),
|
|
833
|
+
inline: this.inlineState(el, cs, outer.inline),
|
|
834
|
+
};
|
|
835
|
+
const tag = el.localName;
|
|
836
|
+
|
|
837
|
+
if (el.hasAttribute(TOC_ATTR)) return this.toc(el, ctx);
|
|
838
|
+
if (tag === 'table') return this.table(el as HTMLTableElement, ctx);
|
|
839
|
+
if (tag === 'ul' || tag === 'ol') return this.list(el, ctx);
|
|
840
|
+
if (MEDIA.has(tag)) {
|
|
841
|
+
await this.picture(el, ctx);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (tag === 'figure') return this.figure(el, ctx);
|
|
845
|
+
const rule = this.ruleBorder(el, cs);
|
|
846
|
+
if (rule) return this.rule(el, rule, ctx);
|
|
847
|
+
if (tag === 'hr') return;
|
|
848
|
+
const items = this.rowOf(el, cs);
|
|
849
|
+
if (items) {
|
|
850
|
+
await this.row(el, items, ctx);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (this.isLeaf(el)) {
|
|
854
|
+
await this.leaf(el, ctx);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const box = this.boxOf(cs);
|
|
859
|
+
return this.walkChildren(el, box ? { ...ctx, box: mergeBoxes(ctx.box, box) } : ctx);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private isLeaf(el: Element): boolean {
|
|
863
|
+
const tag = el.localName;
|
|
864
|
+
if (el.matches(ANY_HEADING)) return true;
|
|
865
|
+
if (tag === 'p' || tag === 'pre' || tag === 'figcaption' || tag === 'caption') return true;
|
|
866
|
+
for (const node of childNodes(el, this.style)) {
|
|
867
|
+
if (!(node instanceof Element)) continue;
|
|
868
|
+
const cs = this.style(node);
|
|
869
|
+
if (!this.skipped(node, cs) && !this.inlineLevel(node, cs)) return false;
|
|
870
|
+
}
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
private async leaf(el: Element, ctx: Ctx): Promise<Paragraph | undefined> {
|
|
875
|
+
const builder = new ParagraphBuilder();
|
|
876
|
+
await this.inlineChildren(el, builder, ctx.inline, ctx);
|
|
877
|
+
return this.emit(builder, el, this.rect(el), ctx, true);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private emit(
|
|
881
|
+
builder: ParagraphBuilder,
|
|
882
|
+
source: Element,
|
|
883
|
+
rect: DOMRect,
|
|
884
|
+
ctx: Ctx,
|
|
885
|
+
own: boolean,
|
|
886
|
+
): Paragraph | undefined {
|
|
887
|
+
if (builder.empty) return undefined;
|
|
888
|
+
const { role, level } = this.roleOf(source, ctx, own);
|
|
889
|
+
const paragraph: Paragraph = {
|
|
890
|
+
type: 'paragraph',
|
|
891
|
+
role,
|
|
892
|
+
level,
|
|
893
|
+
inlines: builder.finish(),
|
|
894
|
+
props: this.paragraphProps(source, rect, ctx, own),
|
|
895
|
+
};
|
|
896
|
+
if (own && role === 'heading' && source.id) paragraph.bookmark = this.bookmarks.get(source.id);
|
|
897
|
+
if (ctx.list?.pending) {
|
|
898
|
+
paragraph.list = { num: ctx.list.num, level: ctx.list.level };
|
|
899
|
+
ctx.list.pending = false;
|
|
900
|
+
}
|
|
901
|
+
ctx.flow.add(paragraph, rect, ctx.positioned);
|
|
902
|
+
return paragraph;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
private roleOf(el: Element, ctx: Ctx, own: boolean): { role: ParagraphRole; level?: number } {
|
|
906
|
+
if (own && el.matches(ANY_HEADING)) {
|
|
907
|
+
// h4–h6 are below what the viewer's outline lists, but Word has styles for them.
|
|
908
|
+
if (!el.matches(HEADING_SELECTOR)) return { role: 'heading', level: Number(el.localName[1]) };
|
|
909
|
+
const level = outlineLevelOf(el);
|
|
910
|
+
if (level !== null) return { role: 'heading', level };
|
|
911
|
+
// Kept out of the outline, so kept out of Word's too: a heading style
|
|
912
|
+
// would put the cover title and "Contents" into the table of contents.
|
|
913
|
+
return { role: levelOf(el) === 1 ? 'title' : 'subtitle' };
|
|
914
|
+
}
|
|
915
|
+
if (own && (el.localName === 'figcaption' || el.localName === 'caption')) {
|
|
916
|
+
return { role: 'caption' };
|
|
917
|
+
}
|
|
918
|
+
return { role: ctx.list && ctx.role === 'body' ? 'list' : ctx.role };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
private paragraphProps(source: Element, rect: DOMRect, ctx: Ctx, own: boolean): ParagraphProps {
|
|
922
|
+
const cs = this.style(source);
|
|
923
|
+
const align = alignOf(cs.textAlign, cs.direction);
|
|
924
|
+
const lineHeight = px(cs.lineHeight);
|
|
925
|
+
const props: ParagraphProps = {
|
|
926
|
+
align,
|
|
927
|
+
line: lineHeight > 0 ? twips(lineHeight) : undefined,
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
const column = ctx.flow.column;
|
|
931
|
+
const box = this.contentBox(source);
|
|
932
|
+
let left = box.left - column.left;
|
|
933
|
+
let right = column.right - box.right;
|
|
934
|
+
// A one-line block shrink-wrapped and centred by its container reads as centred text.
|
|
935
|
+
if (!align && own && rect.height < lineHeight * 1.6 + 1 && source.parentElement) {
|
|
936
|
+
const outer = this.contentBox(source.parentElement);
|
|
937
|
+
const self = this.rect(source);
|
|
938
|
+
if (centred(self.left - outer.left, outer.right - self.right)) {
|
|
939
|
+
props.align = 'center';
|
|
940
|
+
left = 0;
|
|
941
|
+
right = 0;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (left > 1) props.indentLeft = twips(left);
|
|
945
|
+
if (right > 1) props.indentRight = twips(right);
|
|
946
|
+
const indent = px(cs.textIndent);
|
|
947
|
+
if (indent) props.firstLine = twips(indent);
|
|
948
|
+
if (ctx.list?.pending) {
|
|
949
|
+
// The marker hangs in the list's own padding, as it does on the page — and
|
|
950
|
+
// a list flush with the text stays flush, rather than taking the Word
|
|
951
|
+
// numbering level's default indent.
|
|
952
|
+
props.indentLeft ??= 0;
|
|
953
|
+
props.firstLine = -Math.min(props.indentLeft, 360);
|
|
954
|
+
}
|
|
955
|
+
if (cs.direction === 'rtl') props.bidi = true;
|
|
956
|
+
applyFrame(props, own ? mergeBoxes(ctx.box, this.boxOf(cs)) : ctx.box);
|
|
957
|
+
return props;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
private bordersOf(cs: CSSStyleDeclaration, padded: boolean): Borders {
|
|
961
|
+
const borders: Borders = {};
|
|
962
|
+
for (const side of SIDES) {
|
|
963
|
+
const border = this.border(cs, side, padded);
|
|
964
|
+
if (border) borders[side] = border;
|
|
965
|
+
}
|
|
966
|
+
return borders;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
private boxOf(cs: CSSStyleDeclaration): Box | undefined {
|
|
970
|
+
const borders = this.bordersOf(cs, true);
|
|
971
|
+
const shading = this.background(cs);
|
|
972
|
+
if (Object.keys(borders).length === 0 && !shading) return undefined;
|
|
973
|
+
return { borders, shading };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** A side's border, set off from the text by the padding when `padded`. */
|
|
977
|
+
private border(cs: CSSStyleDeclaration, side: Side, padded: boolean): Border | undefined {
|
|
978
|
+
const width = px(cs.getPropertyValue(`border-${side}-width`));
|
|
979
|
+
const kind = cs.getPropertyValue(`border-${side}-style`);
|
|
980
|
+
if (width <= 0 || kind === 'none' || kind === 'hidden') return undefined;
|
|
981
|
+
const color = this.colors.hex(cs.getPropertyValue(`border-${side}-color`));
|
|
982
|
+
if (!color) return undefined;
|
|
983
|
+
const padding = padded ? px(cs.getPropertyValue(`padding-${side}`)) : 0;
|
|
984
|
+
return {
|
|
985
|
+
style: kind === 'dashed' || kind === 'dotted' || kind === 'double' ? kind : 'single',
|
|
986
|
+
size: eighths(width),
|
|
987
|
+
color,
|
|
988
|
+
space: Math.min(31, points(padding)),
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** A fill worth keeping: not transparent, and not the paper it sits on. */
|
|
993
|
+
private background(cs: CSSStyleDeclaration): string | undefined {
|
|
994
|
+
const fill = this.colors.hex(cs.backgroundColor);
|
|
995
|
+
return fill && fill !== this.paper ? fill : undefined;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** The line a thin, empty box draws — its top or bottom border, or its fill. */
|
|
999
|
+
private ruleBorder(el: Element, cs: CSSStyleDeclaration): Border | undefined {
|
|
1000
|
+
const rect = this.rect(el);
|
|
1001
|
+
if (rect.height > 4 || rect.width < 8) return undefined;
|
|
1002
|
+
if (el.textContent?.trim() || el.querySelector(MEDIA_SELECTOR)) return undefined;
|
|
1003
|
+
const fill = this.background(cs);
|
|
1004
|
+
return (
|
|
1005
|
+
this.border(cs, 'top', false) ??
|
|
1006
|
+
this.border(cs, 'bottom', false) ??
|
|
1007
|
+
(fill && rect.height > 0
|
|
1008
|
+
? { style: 'single', size: eighths(rect.height), color: fill, space: 0 }
|
|
1009
|
+
: undefined)
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private rule(el: Element, border: Border, ctx: Ctx): void {
|
|
1014
|
+
const rect = this.rect(el);
|
|
1015
|
+
const column = ctx.flow.column;
|
|
1016
|
+
const props: ParagraphProps = {
|
|
1017
|
+
borders: { bottom: border },
|
|
1018
|
+
shading: ctx.box?.shading,
|
|
1019
|
+
line: 20,
|
|
1020
|
+
lineExact: true,
|
|
1021
|
+
markSize: 2,
|
|
1022
|
+
};
|
|
1023
|
+
if (rect.left - column.left > 1) props.indentLeft = twips(rect.left - column.left);
|
|
1024
|
+
if (column.right - rect.right > 1) props.indentRight = twips(column.right - rect.right);
|
|
1025
|
+
ctx.flow.add({ type: 'paragraph', role: ctx.role, inlines: [], props }, rect, ctx.positioned);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** What a flex or grid row lays out, or null when it holds loose text. */
|
|
1029
|
+
private rowItems(el: Element): Element[] | null {
|
|
1030
|
+
const items: Element[] = [];
|
|
1031
|
+
for (const node of childNodes(el, this.style)) {
|
|
1032
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1033
|
+
if (node.nodeValue?.trim()) return null;
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
if (!(node instanceof Element)) continue;
|
|
1037
|
+
const cs = this.style(node);
|
|
1038
|
+
if (!this.skipped(node, cs) && !isPlaced(cs)) items.push(node);
|
|
1039
|
+
}
|
|
1040
|
+
return items;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* The items of a flex or grid row that sit on one line — a running footer, a
|
|
1045
|
+
* "label … value" line — or null for anything else. Such a row is one
|
|
1046
|
+
* paragraph with tab stops where the items start, so it stays one editable
|
|
1047
|
+
* line in Word instead of a stack.
|
|
1048
|
+
*/
|
|
1049
|
+
private rowOf(el: Element, cs: CSSStyleDeclaration): Element[] | null {
|
|
1050
|
+
const display = cs.display;
|
|
1051
|
+
const flex = display === 'flex' || display === 'inline-flex';
|
|
1052
|
+
if (!flex && display !== 'grid' && display !== 'inline-grid') return null;
|
|
1053
|
+
if (flex && cs.flexDirection.startsWith('column')) return null;
|
|
1054
|
+
const items = this.rowItems(el);
|
|
1055
|
+
if (!items || items.length < 2 || items.some((item) => this.hasBlockInside(item))) return null;
|
|
1056
|
+
// A heading keeps its style and bookmark as a paragraph of its own.
|
|
1057
|
+
if (items.some((item) => item.matches(ANY_HEADING) || item.querySelector(ANY_HEADING))) {
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
const line = px(cs.lineHeight) || px(cs.fontSize) * 1.4 || 20;
|
|
1061
|
+
const tops = items.map((item) => this.rect(item).top);
|
|
1062
|
+
const top = Math.min(...tops);
|
|
1063
|
+
if (!tops.every((t) => t - top < line * 0.8)) return null;
|
|
1064
|
+
// One item may wrap, as a long contents entry does; columns that each run
|
|
1065
|
+
// to several lines — a signature block — are a layout, not a line.
|
|
1066
|
+
const tall = items.filter(
|
|
1067
|
+
(item) => this.rect(item).height > line * 1.5 || item.querySelector('br') !== null,
|
|
1068
|
+
);
|
|
1069
|
+
return tall.length > 1 ? null : items;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
private hasBlockInside(item: Element): boolean {
|
|
1073
|
+
if (MEDIA.has(item.localName)) return false;
|
|
1074
|
+
for (const node of childNodes(item, this.style)) {
|
|
1075
|
+
if (!(node instanceof Element) || MEDIA.has(node.localName)) continue;
|
|
1076
|
+
const cs = this.style(node);
|
|
1077
|
+
if (this.skipped(node, cs)) continue;
|
|
1078
|
+
if (!this.inlineLevel(node, cs) || this.hasBlockInside(node)) return true;
|
|
1079
|
+
}
|
|
1080
|
+
return false;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private async row(el: Element, items: Element[], ctx: Ctx): Promise<Paragraph | undefined> {
|
|
1084
|
+
const builder = new ParagraphBuilder();
|
|
1085
|
+
const column = ctx.flow.column;
|
|
1086
|
+
const box = this.contentBox(el);
|
|
1087
|
+
const tabs: TabStop[] = [];
|
|
1088
|
+
let leader: TabStop['leader'];
|
|
1089
|
+
let first = true;
|
|
1090
|
+
|
|
1091
|
+
for (const item of items) {
|
|
1092
|
+
const cs = this.style(item);
|
|
1093
|
+
const media = MEDIA.has(item.localName) || item.querySelector(MEDIA_SELECTOR) !== null;
|
|
1094
|
+
if (!media && !hasProse(item)) {
|
|
1095
|
+
// An empty item between two others is a spacer — or, with a dotted
|
|
1096
|
+
// rule, the leader a contents line draws.
|
|
1097
|
+
leader = leaderOf(cs) ?? leader;
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
const rect = this.rect(item);
|
|
1101
|
+
if (!first) {
|
|
1102
|
+
const middle = (rect.left + rect.right) / 2;
|
|
1103
|
+
const tab: TabStop =
|
|
1104
|
+
Math.abs(box.right - rect.right) < 2
|
|
1105
|
+
? { align: 'right', pos: twips(rect.right - column.left) }
|
|
1106
|
+
: Math.abs(middle - (box.left + box.right) / 2) < 2
|
|
1107
|
+
? { align: 'center', pos: twips(middle - column.left) }
|
|
1108
|
+
: { align: 'left', pos: twips(rect.left - column.left) };
|
|
1109
|
+
tabs.push({ ...tab, leader });
|
|
1110
|
+
builder.push({ type: 'tab' });
|
|
1111
|
+
}
|
|
1112
|
+
leader = undefined;
|
|
1113
|
+
first = false;
|
|
1114
|
+
await this.inline(item, builder, ctx.inline, ctx, true);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const paragraph = this.emit(builder, el, this.rect(el), ctx, true);
|
|
1118
|
+
if (paragraph && tabs.length > 0) paragraph.props.tabs = tabs;
|
|
1119
|
+
return paragraph;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
private async toc(el: Element, ctx: Ctx): Promise<void> {
|
|
1123
|
+
const depth = Number(el.getAttribute(TOC_DEPTH_ATTR)) || 3;
|
|
1124
|
+
const numbered = el.querySelector(`[${TOC_PAGE_ATTR}]`) !== null;
|
|
1125
|
+
const instruction = `TOC \\o "1-${depth}" \\h \\z \\u${numbered ? '' : ' \\n'}`;
|
|
1126
|
+
|
|
1127
|
+
const entries: Paragraph[] = [];
|
|
1128
|
+
for (const row of Array.from(el.querySelectorAll(`[${TOC_ENTRY_ATTR}]`))) {
|
|
1129
|
+
const anchor = this.bookmarks.get(row.getAttribute(TOC_ENTRY_ATTR) ?? '');
|
|
1130
|
+
const rowCtx: Ctx = {
|
|
1131
|
+
...ctx,
|
|
1132
|
+
role: 'toc',
|
|
1133
|
+
inline: anchor ? { ...ctx.inline, link: { anchor } } : ctx.inline,
|
|
1134
|
+
};
|
|
1135
|
+
const items = this.rowOf(row, this.style(row));
|
|
1136
|
+
const entry = items ? await this.row(row, items, rowCtx) : await this.leaf(row, rowCtx);
|
|
1137
|
+
if (!entry) continue;
|
|
1138
|
+
entry.level = Number(row.getAttribute(TOC_LEVEL_ATTR)) || 1;
|
|
1139
|
+
entries.push(entry);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// The entries are the field's current result: right until Word repaginates,
|
|
1143
|
+
// and rebuilt by Word the moment anyone updates the field.
|
|
1144
|
+
const first = entries[0];
|
|
1145
|
+
const last = entries[entries.length - 1];
|
|
1146
|
+
if (!first || !last) {
|
|
1147
|
+
const empty: Paragraph = {
|
|
1148
|
+
type: 'paragraph',
|
|
1149
|
+
role: 'toc',
|
|
1150
|
+
level: 1,
|
|
1151
|
+
inlines: [],
|
|
1152
|
+
props: {},
|
|
1153
|
+
field: { begin: instruction, end: true },
|
|
1154
|
+
};
|
|
1155
|
+
ctx.flow.add(empty, this.rect(el), ctx.positioned);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
first.field = { begin: instruction };
|
|
1159
|
+
last.field = { ...last.field, end: true };
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
private async list(el: Element, ctx: Ctx): Promise<void> {
|
|
1163
|
+
const nodes = Array.from(childNodes(el, this.style));
|
|
1164
|
+
const isItem = (node: Node): node is Element =>
|
|
1165
|
+
node instanceof Element && node.localName === 'li' && !this.skipped(node, this.style(node));
|
|
1166
|
+
const items = nodes.filter(isItem);
|
|
1167
|
+
const ordered = el.localName === 'ol';
|
|
1168
|
+
const kind = listKindOf(this.style(items[0] ?? el).listStyleType, ordered);
|
|
1169
|
+
if (!kind || items.length === 0) {
|
|
1170
|
+
await this.walkChildren(el, ctx);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
const level = ctx.list ? Math.min(8, ctx.list.level + 1) : 0;
|
|
1174
|
+
const start = ordered ? (el as HTMLOListElement).start : 1;
|
|
1175
|
+
const num = this.lists.length + 1;
|
|
1176
|
+
this.lists.push({ id: num, kind, level, start: Number.isFinite(start) ? start : 1 });
|
|
1177
|
+
for (const node of nodes) {
|
|
1178
|
+
if (!isItem(node)) {
|
|
1179
|
+
// A list nested beside the items, a note between them: the page shows
|
|
1180
|
+
// them, one level in.
|
|
1181
|
+
await this.walkNodes([node], el, { ...ctx, list: { num, level, pending: false } });
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
const cs = this.style(node);
|
|
1185
|
+
// Only a list item draws a marker; a flex or grid <li> is laid out as any block is.
|
|
1186
|
+
if (cs.display === 'list-item') {
|
|
1187
|
+
await this.walkChildren(node, {
|
|
1188
|
+
...ctx,
|
|
1189
|
+
inline: this.inlineState(node, cs, ctx.inline),
|
|
1190
|
+
list: { num, level, pending: true },
|
|
1191
|
+
});
|
|
1192
|
+
} else {
|
|
1193
|
+
await this.block(node, cs, { ...ctx, list: { num, level, pending: false } });
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
private async table(table: HTMLTableElement, ctx: Ctx): Promise<void> {
|
|
1199
|
+
if (table.caption && !this.skipped(table.caption, this.style(table.caption))) {
|
|
1200
|
+
await this.leaf(table.caption, ctx);
|
|
1201
|
+
}
|
|
1202
|
+
const rows = Array.from(table.rows).filter((row) => !this.skipped(row, this.style(row)));
|
|
1203
|
+
const edges: number[] = [];
|
|
1204
|
+
for (const row of rows) {
|
|
1205
|
+
for (const cell of Array.from(row.cells)) {
|
|
1206
|
+
const rect = this.rect(cell);
|
|
1207
|
+
edges.push(rect.left, rect.right);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
// Columns are wherever cell edges line up. Reading the grid off the page
|
|
1211
|
+
// handles colspans without re-deriving the table algorithm.
|
|
1212
|
+
const xs: number[] = [];
|
|
1213
|
+
for (const x of edges.sort((a, b) => a - b)) {
|
|
1214
|
+
const last = xs[xs.length - 1];
|
|
1215
|
+
if (last === undefined || x - last > 1.5) xs.push(x);
|
|
1216
|
+
}
|
|
1217
|
+
if (xs.length < 2) return;
|
|
1218
|
+
|
|
1219
|
+
const column = ctx.flow.column;
|
|
1220
|
+
const available = column.right - column.left;
|
|
1221
|
+
const span = xs[xs.length - 1] - xs[0];
|
|
1222
|
+
const scale = available > 0 && span > available + 1 ? available / span : 1;
|
|
1223
|
+
const columns = xs.slice(1).map((x, i) => Math.max(1, twips((x - xs[i]) * scale)));
|
|
1224
|
+
const gridAt = (x: number) => {
|
|
1225
|
+
let best = 0;
|
|
1226
|
+
xs.forEach((edge, i) => {
|
|
1227
|
+
if (Math.abs(edge - x) < Math.abs(xs[best] - x)) best = i;
|
|
1228
|
+
});
|
|
1229
|
+
return best;
|
|
1230
|
+
};
|
|
1231
|
+
const widthOf = (from: number, to: number) =>
|
|
1232
|
+
columns.slice(from, to).reduce((sum, value) => sum + value, 0);
|
|
1233
|
+
const emptyCell = (from: number, to: number): TableCell => ({
|
|
1234
|
+
blocks: [],
|
|
1235
|
+
span: to - from,
|
|
1236
|
+
width: widthOf(from, to),
|
|
1237
|
+
borders: {},
|
|
1238
|
+
margins: sides(() => 0),
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
const carried: Array<{ grid: number; rows: number; cell: TableCell }> = [];
|
|
1242
|
+
const out: TableRow[] = [];
|
|
1243
|
+
for (const row of rows) {
|
|
1244
|
+
const placed: Array<{ grid: number; cell: TableCell }> = [];
|
|
1245
|
+
for (const carry of carried) {
|
|
1246
|
+
if (carry.rows <= 0) continue;
|
|
1247
|
+
carry.rows -= 1;
|
|
1248
|
+
placed.push({ grid: carry.grid, cell: { ...carry.cell, blocks: [], merge: 'continue' } });
|
|
1249
|
+
}
|
|
1250
|
+
for (const cell of Array.from(row.cells)) {
|
|
1251
|
+
const cs = this.style(cell);
|
|
1252
|
+
if (this.skipped(cell, cs)) continue;
|
|
1253
|
+
const rect = this.rect(cell);
|
|
1254
|
+
const from = gridAt(rect.left);
|
|
1255
|
+
const to = Math.max(from + 1, gridAt(rect.right));
|
|
1256
|
+
const tc = await this.cell(cell, cs, to - from, widthOf(from, to), ctx);
|
|
1257
|
+
if (cell.rowSpan > 1) {
|
|
1258
|
+
tc.merge = 'restart';
|
|
1259
|
+
carried.push({ grid: from, rows: cell.rowSpan - 1, cell: tc });
|
|
1260
|
+
}
|
|
1261
|
+
placed.push({ grid: from, cell: tc });
|
|
1262
|
+
}
|
|
1263
|
+
placed.sort((a, b) => a.grid - b.grid);
|
|
1264
|
+
const cells: TableCell[] = [];
|
|
1265
|
+
let cursor = 0;
|
|
1266
|
+
for (const { grid, cell } of placed) {
|
|
1267
|
+
if (grid < cursor) continue;
|
|
1268
|
+
if (grid > cursor) cells.push(emptyCell(cursor, grid));
|
|
1269
|
+
cells.push(cell);
|
|
1270
|
+
cursor = grid + cell.span;
|
|
1271
|
+
}
|
|
1272
|
+
if (cursor < columns.length) cells.push(emptyCell(cursor, columns.length));
|
|
1273
|
+
out.push({ cells, header: row.parentElement?.localName === 'thead' });
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const rect = this.rect(table);
|
|
1277
|
+
const left = rect.left - column.left;
|
|
1278
|
+
const result: Table = { type: 'table', columns, rows: out };
|
|
1279
|
+
if (centred(left, column.right - rect.right)) result.align = 'center';
|
|
1280
|
+
else if (left > 1) result.indent = twips(left);
|
|
1281
|
+
ctx.flow.add(result, rect, ctx.positioned);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
private async cell(
|
|
1285
|
+
el: HTMLTableCellElement,
|
|
1286
|
+
cs: CSSStyleDeclaration,
|
|
1287
|
+
span: number,
|
|
1288
|
+
width: number,
|
|
1289
|
+
ctx: Ctx,
|
|
1290
|
+
): Promise<TableCell> {
|
|
1291
|
+
const flow = new Flow(this.contentBox(el));
|
|
1292
|
+
await this.walkChildren(
|
|
1293
|
+
el,
|
|
1294
|
+
this.root(flow, 'body', { inline: this.inlineState(el, cs, ctx.inline), fields: ctx.fields }),
|
|
1295
|
+
);
|
|
1296
|
+
|
|
1297
|
+
const row = el.parentElement;
|
|
1298
|
+
const cell: TableCell = {
|
|
1299
|
+
blocks: flow.blocks,
|
|
1300
|
+
span,
|
|
1301
|
+
width,
|
|
1302
|
+
borders: this.bordersOf(cs, false),
|
|
1303
|
+
margins: sides((side) => twips(px(cs.getPropertyValue(`padding-${side}`)))),
|
|
1304
|
+
shading: this.background(cs) ?? (row ? this.background(this.style(row)) : undefined),
|
|
1305
|
+
vAlign:
|
|
1306
|
+
cs.verticalAlign === 'middle'
|
|
1307
|
+
? 'center'
|
|
1308
|
+
: cs.verticalAlign === 'bottom'
|
|
1309
|
+
? 'bottom'
|
|
1310
|
+
: undefined,
|
|
1311
|
+
};
|
|
1312
|
+
// The gap above text the browser centred is the centring; Word does its own.
|
|
1313
|
+
const first = cell.blocks[0];
|
|
1314
|
+
if (cell.vAlign && first?.type === 'paragraph') first.props.spaceBefore = 0;
|
|
1315
|
+
return cell;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
private async figure(figure: Element, ctx: Ctx): Promise<void> {
|
|
1319
|
+
const start = ctx.flow.blocks.length;
|
|
1320
|
+
const nodes = Array.from(childNodes(figure, this.style));
|
|
1321
|
+
if (nodes.some((node) => node.nodeType === Node.TEXT_NODE && node.nodeValue?.trim())) {
|
|
1322
|
+
await this.walkChildren(figure, ctx);
|
|
1323
|
+
} else {
|
|
1324
|
+
for (const node of nodes) {
|
|
1325
|
+
if (!(node instanceof Element)) continue;
|
|
1326
|
+
const cs = this.style(node);
|
|
1327
|
+
if (this.skipped(node, cs)) continue;
|
|
1328
|
+
if (node.localName === 'figcaption') await this.leaf(node, ctx);
|
|
1329
|
+
else if (node.matches(STRUCTURAL) || node.querySelector(STRUCTURAL)) {
|
|
1330
|
+
await this.block(node, cs, ctx);
|
|
1331
|
+
} else if (!(await this.picture(this.soleMedia(node) ?? node, ctx))) {
|
|
1332
|
+
// A drawing that would not rasterize still has its words.
|
|
1333
|
+
await this.block(node, cs, ctx);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const added = ctx.flow.blocks.slice(start);
|
|
1339
|
+
// A caption travels with what it captions.
|
|
1340
|
+
for (const block of added.slice(0, -1)) {
|
|
1341
|
+
if (block.type === 'paragraph') block.props.keepNext = true;
|
|
1342
|
+
}
|
|
1343
|
+
const id = figure.getAttribute(LABEL_ID_ATTR);
|
|
1344
|
+
const bookmark = id ? this.bookmarks.get(id) : undefined;
|
|
1345
|
+
const target =
|
|
1346
|
+
added.find((block): block is Paragraph => block.type === 'paragraph') ??
|
|
1347
|
+
firstParagraphIn(added);
|
|
1348
|
+
if (bookmark && target && !target.bookmark) target.bookmark = bookmark;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** The one picture a wrapper exists to hold — a `<Diagram>`'s SVG, an image in a frame. */
|
|
1352
|
+
private soleMedia(el: Element): Element | null {
|
|
1353
|
+
const media = Array.from(el.querySelectorAll(MEDIA_SELECTOR)).filter(
|
|
1354
|
+
(node) => !node.parentElement?.closest('svg'),
|
|
1355
|
+
);
|
|
1356
|
+
return media.length === 1 && !hasProse(el) ? media[0] : null;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* An element as a picture paragraph. Anything that is not an image — a chart
|
|
1361
|
+
* drawn with divs, a shaded diagram — becomes a picture of itself: it is a
|
|
1362
|
+
* drawing on the page, and in Word it has to stay one, not collapse into a
|
|
1363
|
+
* column of stray labels. False when no picture could be made.
|
|
1364
|
+
*/
|
|
1365
|
+
private async picture(el: Element, ctx: Ctx): Promise<boolean> {
|
|
1366
|
+
const rect = this.rect(el);
|
|
1367
|
+
if (rect.width < 1 || rect.height < 1) return true;
|
|
1368
|
+
const column = ctx.flow.column;
|
|
1369
|
+
const image = await this.image(el, column, ctx.inline.link);
|
|
1370
|
+
if (!image) return false;
|
|
1371
|
+
|
|
1372
|
+
// In a box, the picture's paragraph spans the box as its neighbours' do, so
|
|
1373
|
+
// Word draws one frame around them all rather than a step at the picture.
|
|
1374
|
+
const within = ctx.box && el.parentElement ? this.contentBox(el.parentElement) : column;
|
|
1375
|
+
const props: ParagraphProps = {};
|
|
1376
|
+
if (within.left - column.left > 1) props.indentLeft = twips(within.left - column.left);
|
|
1377
|
+
if (column.right - within.right > 1) props.indentRight = twips(column.right - within.right);
|
|
1378
|
+
const left = rect.left - within.left;
|
|
1379
|
+
const right = within.right - rect.right;
|
|
1380
|
+
if (centred(left, right)) props.align = 'center';
|
|
1381
|
+
else if (left > 2 && right < 2) props.align = 'right';
|
|
1382
|
+
else if (left > 1) props.indentLeft = (props.indentLeft ?? 0) + twips(left);
|
|
1383
|
+
applyFrame(props, ctx.box);
|
|
1384
|
+
ctx.flow.add(
|
|
1385
|
+
{ type: 'paragraph', role: ctx.role, inlines: [image], props },
|
|
1386
|
+
rect,
|
|
1387
|
+
ctx.positioned,
|
|
1388
|
+
);
|
|
1389
|
+
return true;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/** A picture of the element, no wider than the column; null when none could be made. */
|
|
1393
|
+
private async image(
|
|
1394
|
+
el: Element,
|
|
1395
|
+
column: Column,
|
|
1396
|
+
link: LinkTarget | undefined,
|
|
1397
|
+
): Promise<Inline | null> {
|
|
1398
|
+
const rect = this.rect(el);
|
|
1399
|
+
if (rect.width < 1 || rect.height < 1) return null;
|
|
1400
|
+
const media = await this.media.of(el, rect);
|
|
1401
|
+
if (media === null) return null;
|
|
1402
|
+
const available = column.right - column.left;
|
|
1403
|
+
const scale = available > 0 && rect.width > available ? available / rect.width : 1;
|
|
1404
|
+
return {
|
|
1405
|
+
type: 'image',
|
|
1406
|
+
image: {
|
|
1407
|
+
media,
|
|
1408
|
+
width: emu(rect.width * scale),
|
|
1409
|
+
height: emu(rect.height * scale),
|
|
1410
|
+
alt: el.getAttribute('alt') ?? el.getAttribute('aria-label') ?? '',
|
|
1411
|
+
},
|
|
1412
|
+
link,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
private inlineState(el: Element, cs: CSSStyleDeclaration, outer: InlineState): InlineState {
|
|
1417
|
+
const next: InlineState = { ...outer };
|
|
1418
|
+
const decoration = cs.textDecorationLine;
|
|
1419
|
+
if (decoration.includes('underline')) next.underline = true;
|
|
1420
|
+
if (decoration.includes('line-through')) next.strike = true;
|
|
1421
|
+
if (cs.display.startsWith('inline')) {
|
|
1422
|
+
if (cs.verticalAlign === 'super' || cs.verticalAlign === 'sub') {
|
|
1423
|
+
next.vertAlign = cs.verticalAlign === 'sub' ? 'subscript' : 'superscript';
|
|
1424
|
+
next.baseSize = halfPoints(px(this.style(el.parentElement ?? el).fontSize));
|
|
1425
|
+
}
|
|
1426
|
+
const shading = this.background(cs);
|
|
1427
|
+
if (shading) next.shading = shading;
|
|
1428
|
+
}
|
|
1429
|
+
if (el instanceof HTMLAnchorElement) {
|
|
1430
|
+
const link = this.linkOf(el);
|
|
1431
|
+
if (link) next.link = link;
|
|
1432
|
+
}
|
|
1433
|
+
const to = el.getAttribute(REF_ATTR);
|
|
1434
|
+
const anchor = to === null ? undefined : this.bookmarks.get(to);
|
|
1435
|
+
if (anchor) next.link = { anchor };
|
|
1436
|
+
return next;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
private linkOf(a: HTMLAnchorElement): LinkTarget | undefined {
|
|
1440
|
+
const href = a.getAttribute('href');
|
|
1441
|
+
if (!href) return undefined;
|
|
1442
|
+
if (href.startsWith('#')) {
|
|
1443
|
+
const fragment = href.slice(1);
|
|
1444
|
+
let id = fragment;
|
|
1445
|
+
try {
|
|
1446
|
+
id = decodeURIComponent(fragment);
|
|
1447
|
+
} catch {
|
|
1448
|
+
/* `#growth-50%` is not percent-encoding; the fragment is the id as written. */
|
|
1449
|
+
}
|
|
1450
|
+
const anchor = this.bookmarks.get(id) ?? this.bookmarks.get(fragment);
|
|
1451
|
+
return anchor ? { anchor } : undefined;
|
|
1452
|
+
}
|
|
1453
|
+
try {
|
|
1454
|
+
const url = new URL(href, window.location.href);
|
|
1455
|
+
if (url.protocol === 'mailto:' || url.protocol === 'tel:') return { url: url.href };
|
|
1456
|
+
// A link back into the viewer points at a dev server the reader does not have.
|
|
1457
|
+
if (/^https?:$/.test(url.protocol) && url.origin !== window.location.origin) {
|
|
1458
|
+
return { url: url.href };
|
|
1459
|
+
}
|
|
1460
|
+
} catch {
|
|
1461
|
+
/* Not a URL; keep the text, drop the link. */
|
|
1462
|
+
}
|
|
1463
|
+
return undefined;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
private runStyle(el: Element, state: InlineState): RunStyle {
|
|
1467
|
+
let byState = this.runs.get(el);
|
|
1468
|
+
const known = byState?.get(state);
|
|
1469
|
+
if (known) return known;
|
|
1470
|
+
const cs = this.style(el);
|
|
1471
|
+
const spacing = px(cs.letterSpacing);
|
|
1472
|
+
const style: RunStyle = {
|
|
1473
|
+
fonts: this.fontsOf(cs.fontFamily),
|
|
1474
|
+
size: state.vertAlign && state.baseSize ? state.baseSize : halfPoints(px(cs.fontSize)),
|
|
1475
|
+
bold: Number.parseInt(cs.fontWeight, 10) >= 600,
|
|
1476
|
+
italic: cs.fontStyle !== 'normal',
|
|
1477
|
+
underline: state.underline,
|
|
1478
|
+
strike: state.strike,
|
|
1479
|
+
color: this.colors.hex(cs.color) ?? '000000',
|
|
1480
|
+
shading: state.shading,
|
|
1481
|
+
vertAlign: state.vertAlign,
|
|
1482
|
+
caps: cs.textTransform === 'uppercase' || undefined,
|
|
1483
|
+
smallCaps:
|
|
1484
|
+
cs.fontVariantCaps === 'small-caps' || cs.fontVariantCaps === 'all-small-caps' || undefined,
|
|
1485
|
+
spacing: spacing ? twips(spacing) : undefined,
|
|
1486
|
+
};
|
|
1487
|
+
if (!byState) {
|
|
1488
|
+
byState = new WeakMap();
|
|
1489
|
+
this.runs.set(el, byState);
|
|
1490
|
+
}
|
|
1491
|
+
byState.set(state, style);
|
|
1492
|
+
return style;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
private fontsOf(family: string): FontSet {
|
|
1496
|
+
const known = this.fontSets.get(family);
|
|
1497
|
+
if (known) return known;
|
|
1498
|
+
const fonts = pickFonts(family);
|
|
1499
|
+
this.fontSets.set(family, fonts);
|
|
1500
|
+
const kind = fontKindOf(family);
|
|
1501
|
+
const declare = (name: string, eastAsia: boolean) => {
|
|
1502
|
+
const existing = this.declarations.get(name);
|
|
1503
|
+
if (!existing) this.declarations.set(name, { name, kind, eastAsia });
|
|
1504
|
+
else if (eastAsia) existing.eastAsia = true;
|
|
1505
|
+
};
|
|
1506
|
+
declare(fonts.ascii, false);
|
|
1507
|
+
if (fonts.eastAsia) declare(fonts.eastAsia, true);
|
|
1508
|
+
return fonts;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
private async inlineChildren(
|
|
1512
|
+
el: Element,
|
|
1513
|
+
builder: ParagraphBuilder,
|
|
1514
|
+
state: InlineState,
|
|
1515
|
+
ctx: Ctx,
|
|
1516
|
+
): Promise<void> {
|
|
1517
|
+
for (const node of childNodes(el, this.style)) {
|
|
1518
|
+
if (node.nodeType === Node.TEXT_NODE) this.text(node as Text, builder, state, ctx);
|
|
1519
|
+
else if (node instanceof Element) await this.inline(node, builder, state, ctx);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/** `item`: a row's item, laid out as a block but set on the row's one line. */
|
|
1524
|
+
private async inline(
|
|
1525
|
+
el: Element,
|
|
1526
|
+
builder: ParagraphBuilder,
|
|
1527
|
+
outer: InlineState,
|
|
1528
|
+
ctx: Ctx,
|
|
1529
|
+
item = false,
|
|
1530
|
+
): Promise<void> {
|
|
1531
|
+
const cs = this.style(el);
|
|
1532
|
+
if (this.skipped(el, cs)) return;
|
|
1533
|
+
const tag = el.localName;
|
|
1534
|
+
if (tag === 'br') {
|
|
1535
|
+
builder.push({ type: 'break' });
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
if (MEDIA.has(tag)) {
|
|
1539
|
+
const image = await this.image(el, ctx.flow.column, outer.link);
|
|
1540
|
+
if (image) builder.push(image);
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
if (el.matches(FOOTNOTE_MARKER) && (await this.footnote(el, builder, outer))) return;
|
|
1544
|
+
// The page a reference quotes is the viewer's; Word's is a PAGEREF away.
|
|
1545
|
+
if (el.hasAttribute(REF_PAGE_ATTR) && outer.link && 'anchor' in outer.link) {
|
|
1546
|
+
builder.push({
|
|
1547
|
+
type: 'field',
|
|
1548
|
+
instr: `PAGEREF ${outer.link.anchor} \\h`,
|
|
1549
|
+
cached: el.textContent ?? '',
|
|
1550
|
+
style: this.runStyle(el, outer),
|
|
1551
|
+
link: outer.link,
|
|
1552
|
+
});
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const block = !item && !this.inlineLevel(el, cs);
|
|
1557
|
+
if (block) builder.requestBreak();
|
|
1558
|
+
await this.inlineChildren(el, builder, this.inlineState(el, cs, outer), ctx);
|
|
1559
|
+
if (block) builder.requestBreak();
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
private text(node: Text, builder: ParagraphBuilder, state: InlineState, ctx: Ctx): void {
|
|
1563
|
+
const parent = node.parentElement;
|
|
1564
|
+
if (!parent) return;
|
|
1565
|
+
const cs = this.style(parent);
|
|
1566
|
+
let value = node.data;
|
|
1567
|
+
if (cs.textTransform === 'lowercase') value = value.toLowerCase();
|
|
1568
|
+
else if (cs.textTransform === 'capitalize') value = capitalize(value, builder.lastChar());
|
|
1569
|
+
const style = this.runStyle(parent, state);
|
|
1570
|
+
const { page, count } = this.options.sentinels;
|
|
1571
|
+
const put = (text: string, collapse: boolean) => {
|
|
1572
|
+
if (!ctx.fields) {
|
|
1573
|
+
builder.text(text, style, state.link, collapse);
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
for (const part of text.split(this.sentinel)) {
|
|
1577
|
+
if (part === String(page)) {
|
|
1578
|
+
builder.push({ type: 'field', instr: 'PAGE', cached: '1', style });
|
|
1579
|
+
} else if (part === String(count)) {
|
|
1580
|
+
const cached = String(this.options.pageCount);
|
|
1581
|
+
builder.push({ type: 'field', instr: 'NUMPAGES', cached, style });
|
|
1582
|
+
} else {
|
|
1583
|
+
builder.text(part, style, state.link, collapse);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
|
|
1588
|
+
const ws = cs.whiteSpace;
|
|
1589
|
+
if (ws === 'pre' || ws === 'pre-wrap' || ws === 'break-spaces' || ws === 'pre-line') {
|
|
1590
|
+
const tab = ' '.repeat(Number.parseInt(cs.tabSize, 10) || 8);
|
|
1591
|
+
value
|
|
1592
|
+
.replace(/\r\n?/g, '\n')
|
|
1593
|
+
.split('\n')
|
|
1594
|
+
.forEach((line, i) => {
|
|
1595
|
+
if (i > 0) builder.push({ type: 'break', style });
|
|
1596
|
+
if (ws === 'pre-line') put(line.replace(/[ \t]+/g, ' '), true);
|
|
1597
|
+
else put(line.replace(/\t/g, tab), false);
|
|
1598
|
+
});
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
put(value.replace(/[\t\n\r\f ]+/g, ' '), true);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
private async footnote(
|
|
1605
|
+
marker: Element,
|
|
1606
|
+
builder: ParagraphBuilder,
|
|
1607
|
+
state: InlineState,
|
|
1608
|
+
): Promise<boolean> {
|
|
1609
|
+
const id = marker.getAttribute(LABEL_ID_ATTR);
|
|
1610
|
+
const body = id ? this.noteBodies.get(id) : undefined;
|
|
1611
|
+
// A marker whose note was never printed stays what it is on the page.
|
|
1612
|
+
if (!id || !body) return false;
|
|
1613
|
+
this.noteBodies.delete(id);
|
|
1614
|
+
|
|
1615
|
+
const number = this.footnotes.length + 1;
|
|
1616
|
+
builder.push({ type: 'footnote', id: number, style: this.runStyle(marker, state) });
|
|
1617
|
+
const box = this.contentBox(body);
|
|
1618
|
+
const flow = new Flow({ left: box.left, right: box.right, top: this.rect(body).top });
|
|
1619
|
+
this.footnotes.push({ id: number, blocks: flow.blocks });
|
|
1620
|
+
await this.walkChildren(body, this.root(flow, 'footnote'));
|
|
1621
|
+
return true;
|
|
1622
|
+
}
|
|
1623
|
+
}
|