@opencraw/mcp 0.1.45 → 0.1.47
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 +27 -1
- package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
- package/dist/diff-tool/index.d.ts +3 -0
- package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/docs-tool/index.d.ts +3 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.esm.js +430 -1
- package/dist/list-tool/index.d.ts +3 -0
- package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
- package/dist/main.d.ts +7 -0
- package/dist/probe-tool/index.d.ts +2 -0
- package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
- package/dist/recipe-source/index.d.ts +3 -0
- package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
- package/dist/run-tool/index.d.ts +3 -0
- package/dist/run-tool/run-tool.handler.d.ts +51 -0
- package/dist/server/create-server.use-case.d.ts +11 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
- package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
- package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
- package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
- package/dist/src/docs-tool/index.d.ts +3 -0
- package/dist/src/server/create-server.use-case.d.ts +1 -1
- package/dist/src/studio-tool/index.d.ts +2 -0
- package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/src/studio-tool/studio-tools.handler.d.ts +47 -0
- package/dist/studio-tool/index.d.ts +2 -0
- package/dist/studio-tool/studio-command.client.d.ts +11 -0
- package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
- package/dist/studio-tool/studio-tools.handler.d.ts +47 -0
- package/dist/validate-tool/index.d.ts +3 -0
- package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
- package/docs/how-it-works/01-recipes.md +698 -0
- package/docs/how-it-works/02-page-steps.md +672 -0
- package/docs/how-it-works/03-data-steps.md +813 -0
- package/docs/how-it-works/04-flow-steps.md +738 -0
- package/docs/how-it-works/05-templates-and-scope.md +428 -0
- package/docs/how-it-works/06-documents.md +1413 -0
- package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
- package/docs/how-it-works/08-policies.md +405 -0
- package/docs/how-it-works/09-running.md +804 -0
- package/docs/how-it-works/README.md +225 -0
- package/docs/recipes/README.md +22 -0
- package/docs/recipes/access.md +440 -0
- package/docs/recipes/authoring.md +1503 -0
- package/docs/recipes/azure-durable.md +180 -0
- package/docs/recipes/captcha.md +204 -0
- package/docs/recipes/movies-example.md +12 -0
- package/docs/recipes/programmatic-authoring.md +101 -0
- package/docs/recipes/quick-guide.md +157 -0
- package/docs/recipes/vehicles-example.md +9 -0
- package/docs/recipes/worker-mode.md +221 -0
- package/docs/studio/manual.md +312 -0
- package/docs/studio/pdf-agreements.md +150 -0
- package/package.json +29 -4
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="../assets/opencraw-logo.svg" alt="OpenCraw" width="360">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Documents, under the hood
|
|
6
|
+
|
|
7
|
+
Part 7 of [How OpenCraw works](README.md). Next: [8. Mapping and transforms](07-mapping-and-transforms.md).
|
|
8
|
+
|
|
9
|
+
A recipe that fetches a PDF, a spreadsheet or a Word file uses the same `extract` step as one that reads a
|
|
10
|
+
web page. This page explains what happens between the bytes and the step: how the format is chosen, what each
|
|
11
|
+
reader builds, and the algorithms that turn a page of positioned text or a grid of cells into rows. PDF gets
|
|
12
|
+
the most space, because a PDF has no rows or columns: the reader has to infer them from where the text is.
|
|
13
|
+
|
|
14
|
+
[authoring.md §4.6–4.13](../recipes/authoring.md#46-pdfs) is the reference for every option named here. Code is
|
|
15
|
+
cited as `file:line` under [`packages/core/src`](../../packages/core/src) or
|
|
16
|
+
[`packages/office-reader/src`](../../packages/office-reader/src).
|
|
17
|
+
|
|
18
|
+
## Contents
|
|
19
|
+
|
|
20
|
+
1. [From bytes to a document](#1-from-bytes-to-a-document)
|
|
21
|
+
2. [PDF](#2-pdf)
|
|
22
|
+
1. [What pdf.js gives, and what is kept](#21-what-pdfjs-gives-and-what-is-kept)
|
|
23
|
+
2. [Runs to cells](#22-runs-to-cells)
|
|
24
|
+
3. [Cells to rows: overlap, not baselines](#23-cells-to-rows-overlap-not-baselines)
|
|
25
|
+
4. [The constants, in points](#24-the-constants-in-points)
|
|
26
|
+
5. [Finding a table](#25-finding-a-table)
|
|
27
|
+
6. [Columns: bands from the body's left edges](#26-columns-bands-from-the-bodys-left-edges)
|
|
28
|
+
7. [Mapping bands to headers](#27-mapping-bands-to-headers)
|
|
29
|
+
8. [Placing values](#28-placing-values)
|
|
30
|
+
9. [Regrouping wrapped cells](#29-regrouping-wrapped-cells)
|
|
31
|
+
10. [Naming the columns, and `fillDown`](#210-naming-the-columns-and-filldown)
|
|
32
|
+
11. [What fails, and why](#211-what-fails-and-why)
|
|
33
|
+
3. [Spreadsheets and CSV](#3-spreadsheets-and-csv)
|
|
34
|
+
4. [PowerPoint](#4-powerpoint)
|
|
35
|
+
5. [Word](#5-word)
|
|
36
|
+
6. [HTML tables](#6-html-tables)
|
|
37
|
+
7. [Markdown](#7-markdown)
|
|
38
|
+
8. [YAML](#8-yaml)
|
|
39
|
+
9. [XML](#9-xml)
|
|
40
|
+
10. [JSON and JSON Lines](#10-json-and-json-lines)
|
|
41
|
+
11. [Constants at a glance](#11-constants-at-a-glance)
|
|
42
|
+
|
|
43
|
+
The examples run against the real documents: the Stellantis discount sheet ENPAM publishes each month (a live
|
|
44
|
+
PDF), and the copies of a GSA workbook, a DfE deck and an FCC Word document shipped in [`examples/`](../../examples).
|
|
45
|
+
The repository's test fixtures fill the gaps, because each one was built to hold the quirks its reader handles.
|
|
46
|
+
The `doc-` recipes name a repository file by its GitHub URL, and a small hook, `repoFile`, turns that URL into
|
|
47
|
+
a `file:` URL into the local checkout ([`capture/scenes/06-documents.mjs`](capture/scenes/06-documents.mjs)).
|
|
48
|
+
|
|
49
|
+
## 1. From bytes to a document
|
|
50
|
+
|
|
51
|
+
### 1.1 Choosing the reader
|
|
52
|
+
|
|
53
|
+
Documents reach a recipe three ways: a `request` over HTTP, a `request` to a `file:` URL, and a web-mode
|
|
54
|
+
`click` with `download`. A relative `file:` URL (`file:data/listino.csv`, `file:../x.pdf`) in a recipe read from a
|
|
55
|
+
file resolves against that file's folder when the recipe loads; in a recipe given as an object, or made relative
|
|
56
|
+
only by a template, it resolves against the working directory when the step runs. The format is chosen in this
|
|
57
|
+
order ([`http-session/http.client.ts`](../../packages/core/src/http-session/http.client.ts)):
|
|
58
|
+
|
|
59
|
+
1. **`as` wins.** `httpRequest.as ?? formatOfResponse(…)` for a response (`:162`), `as ?? formatOfFile(…)` for a
|
|
60
|
+
file or a download (`:149`, `:175`).
|
|
61
|
+
2. **An HTTP response is typed by its `Content-Type`**, lower-cased, parameters dropped (`:242-248`), unless the
|
|
62
|
+
type is one servers send for anything: none, `application/octet-stream`, `binary/octet-stream`, `text/plain`,
|
|
63
|
+
`application/zip` and the download types. A specific type goes through these rules in order, first match wins
|
|
64
|
+
(`:263-280`):
|
|
65
|
+
|
|
66
|
+
| Content type | Format |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `application/gzip` or `application/x-gzip`, **and** a URL path ending `.xml.gz` | `xml` |
|
|
69
|
+
| `text/csv`, `application/csv`, `text/x-csv`, `application/x-csv`, `text/comma-separated-values`, `text/tab-separated-values` | `csv` |
|
|
70
|
+
| `application/x-ndjson`, `application/ndjson`, `application/jsonl`, `application/x-jsonlines`, `application/jsonlines` | `jsonl` |
|
|
71
|
+
| contains `spreadsheetml`, or starts `application/vnd.ms-excel` | `xlsx` |
|
|
72
|
+
| contains `presentationml`, or starts `application/vnd.ms-powerpoint` | `pptx` |
|
|
73
|
+
| `application/msword`, contains `wordprocessingml`, or starts `application/vnd.ms-word` | `docx` |
|
|
74
|
+
| `application/yaml`, `application/x-yaml`, `text/yaml`, `text/x-yaml` | `yaml` |
|
|
75
|
+
| `text/markdown`, `text/x-markdown` | `markdown` |
|
|
76
|
+
| contains `json` (so `application/ld+json`) | `json` |
|
|
77
|
+
| contains `pdf`, then `html`, then `xml` (so `application/xhtml+xml` is `html`) | `pdf`, `html`, `xml` |
|
|
78
|
+
| anything else | `text` |
|
|
79
|
+
|
|
80
|
+
3. **A generic type is sniffed** ([`body-sniff.algorithm.ts`](../../packages/core/src/http-session/body-sniff.algorithm.ts)):
|
|
81
|
+
`%PDF-` within the first 1 KiB is a PDF; a zip (`50 4B 03 04`) is typed by its part names, read from the zip's
|
|
82
|
+
directory without inflating anything: `xl/` a workbook, `ppt/` a deck, `word/` a Word document; gzip
|
|
83
|
+
(`1F 8B`) is XML only when the URL path ends `.xml.gz`. When the bytes prove nothing, the URL path's
|
|
84
|
+
extension decides (rule 4), except on a 4xx or 5xx response, whose body is an error page whatever its URL
|
|
85
|
+
says; then it's `text`. So a PDF served as `application/octet-stream` reads as a PDF with no `as`.
|
|
86
|
+
4. **A file or a download is typed by its extension** (`:289-295`), case-insensitively: `.json`; `.jsonl`
|
|
87
|
+
`.ndjson`; `.pdf`; `.csv` `.tsv`; `.xlsx` `.xlsm` `.xls`; `.pptx` `.pptm` `.ppsx` `.ppt`; `.docx` `.docm`
|
|
88
|
+
`.dotx` `.doc`; `.yaml` `.yml`; `.md` `.markdown`; `.html` `.htm`; `.xml` `.rss` `.atom` `.kml` `.gpx`
|
|
89
|
+
and `.xml.gz`. A name with no known extension is sniffed as in rule 3; anything else is `text`.
|
|
90
|
+
|
|
91
|
+
A **specific** content type is trusted: a PDF served as `text/html` is read as HTML unless the recipe says
|
|
92
|
+
`as: "pdf"`. That is why the Stellantis recipe sets `as`, which costs nothing and survives a server that changes
|
|
93
|
+
its headers. Past the choice, bytes are also inspected inside a reader: text decoding looks for a byte-order mark
|
|
94
|
+
(§3.1), the XML reader gunzips a body that starts with `1F 8B` (§9), `office-reader` recognises a legacy or
|
|
95
|
+
encrypted compound file by its `D0 CF 11 E0 A1 B1 1A E1` signature (§3.3), and pdf.js refuses bytes that are
|
|
96
|
+
not a PDF.
|
|
97
|
+
|
|
98
|
+
A 4xx or 5xx response is parsed by format too before `HttpError` is thrown (`:121-123`), so the block detector
|
|
99
|
+
can read its text.
|
|
100
|
+
|
|
101
|
+
**The readers load lazily.** Each is a dynamic `import()` on first use, so a recipe that never reads a format
|
|
102
|
+
never loads its library: pdf.js ([`read-pdf.client.ts:21`](../../packages/core/src/pdf-document/read-pdf.client.ts#L21)),
|
|
103
|
+
the three entry points of `@opencraw/office-reader`, `/xlsx`, `/pptx` and `/docx`
|
|
104
|
+
([`read-xlsx.client.ts:19`](../../packages/core/src/workbook-document/read-xlsx.client.ts#L19),
|
|
105
|
+
[`read-pptx.client.ts:17`](../../packages/core/src/deck-document/read-pptx.client.ts#L17),
|
|
106
|
+
[`read-docx-html.client.ts:27`](../../packages/core/src/docx-document/read-docx-html.client.ts#L27)), `marked`
|
|
107
|
+
([`read-markdown.client.ts:47`](../../packages/core/src/markdown-document/read-markdown.client.ts#L47)) and
|
|
108
|
+
`yaml` ([`read-yaml.client.ts:30`](../../packages/core/src/yaml-document/read-yaml.client.ts#L30)).
|
|
109
|
+
|
|
110
|
+
### 1.2 What the step gets
|
|
111
|
+
|
|
112
|
+
Every reader produces one of seven document kinds. The `request` binds it as the scope's current document, the
|
|
113
|
+
one an `extract` without `from` reads, and binds `documentValue(body)` under the step's `id`
|
|
114
|
+
([`send-request.use-case.ts:65-66`](../../packages/core/src/api-steps/send-request.use-case.ts#L65-L66),
|
|
115
|
+
`:94`):
|
|
116
|
+
|
|
117
|
+
| Format | Document kind | What the step's `id` holds |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `json`, `jsonl`, `yaml` | `json` | the parsed data |
|
|
120
|
+
| `html`, `markdown`, `docx` | `html` | the HTML string |
|
|
121
|
+
| `text` | `text` | the string |
|
|
122
|
+
| `pdf` | `pdf` | the document object: `{ kind, pages }` |
|
|
123
|
+
| `csv`, `xlsx` | `workbook` | the document object: `{ kind, sheets, csv? }` |
|
|
124
|
+
| `pptx` | `deck` | the document object: `{ kind, width, height, slides }` |
|
|
125
|
+
| `xml` | `xml` | the document object: `{ kind, xml }` |
|
|
126
|
+
|
|
127
|
+
Markdown and Word become HTML, so their `id` holds a string. An `extract` with `from` on that id reads it as
|
|
128
|
+
HTML for `css` and `table`, as markup for `xpath`, and as text for `regex`: `table` with `from` accepts a PDF,
|
|
129
|
+
workbook or deck object, or HTML text, or a list of HTML fragments, which it reads through the HTML table reader
|
|
130
|
+
of §6 ([`extract-from-document.use-case.ts:185-190`](../../packages/core/src/api-steps/extract-from-document.use-case.ts#L185-L190)).
|
|
131
|
+
|
|
132
|
+
### 1.3 Which `extract` kind reads which document
|
|
133
|
+
|
|
134
|
+
([`extract-from-document.use-case.ts:29-79`](../../packages/core/src/api-steps/extract-from-document.use-case.ts#L29-L79))
|
|
135
|
+
|
|
136
|
+
| Kind | pdf | workbook | deck | html | xml | json | text |
|
|
137
|
+
|---|---|---|---|---|---|---|---|
|
|
138
|
+
| `table` | ✓ positions (§2) | ✓ grid (§3.4) | ✓ grid or positions (§4) | ✓ grid (§6) | – | – | – |
|
|
139
|
+
| `jsonpath` | the object | the object | the object | – | – | the data | – |
|
|
140
|
+
| `css` | – | – | – | ✓ | ✓ (cheerio, XML mode) | – | – |
|
|
141
|
+
| `xpath` | – | – | – | ✓ (parsed as a browser does) | ✓ | – | ✓ (parsed as XML) |
|
|
142
|
+
| `regex` | `pdfText` | `workbookText` | `deckText` | the markup | the markup | re-serialised | the text |
|
|
143
|
+
|
|
144
|
+
`regex` reads a text rendering of each document: a PDF's rows, cells joined by a tab, pages separated by a
|
|
145
|
+
blank line; a workbook's visible sheets and rows the same way; a deck's visible slides, each its shape texts,
|
|
146
|
+
its table rows and `Notes: …`. All `table` patterns (`selector`, `until`, `columns`, `sheet`, `slide`) compile
|
|
147
|
+
as case-insensitive regular expressions (`:136-158`), and options that belong to another kind of document fail
|
|
148
|
+
the step: `sheet` on a PDF, `slide` on a workbook (`:142-146`). In web mode, `table` reads the live page's
|
|
149
|
+
`page.content()` through the HTML table reader
|
|
150
|
+
([`extract-from-page.use-case.ts:21`](../../packages/core/src/web-steps/extract-from-page.use-case.ts#L21)).
|
|
151
|
+
|
|
152
|
+
## 2. PDF
|
|
153
|
+
|
|
154
|
+
A PDF page is a list of drawing operations. The text layer says "draw these glyphs at this point, in this font,
|
|
155
|
+
at this size", and nothing more: there are no cells, no rows, no columns. Ruling lines, if any, are separate
|
|
156
|
+
drawings with no link to the text. The reader rebuilds a table in four stages:
|
|
157
|
+
|
|
158
|
+
1. **Runs.** pdf.js returns the page's text items: strings at positions.
|
|
159
|
+
2. **Cells.** Runs on one baseline that nearly touch join into one cell.
|
|
160
|
+
3. **Rows.** Cells whose vertical extents overlap form one row.
|
|
161
|
+
4. **Tables.** A `table` extract finds a header row, clusters the body's cells into columns by their left
|
|
162
|
+
edges, maps the columns to the header cells, and regroups lines that belong to one row.
|
|
163
|
+
|
|
164
|
+
Stages 1 to 3 run once, when the PDF is read ([`read-pdf.client.ts`](../../packages/core/src/pdf-document/read-pdf.client.ts),
|
|
165
|
+
[`row-assembly.algorithm.ts`](../../packages/core/src/pdf-document/row-assembly.algorithm.ts)). Stage 4 runs per
|
|
166
|
+
`extract` ([`pdf-table.algorithm.ts`](../../packages/core/src/pdf-document/pdf-table.algorithm.ts)).
|
|
167
|
+
|
|
168
|
+
The figures below are drawn by the capture script: pdf.js renders the page, then every cell `readPdf` found is
|
|
169
|
+
outlined, each row in one colour (blue and green alternate), and the rows the table `selector` matches are filled
|
|
170
|
+
orange. Here is page 1 of the September 2026 sheet, 1008 × 1427 points, read by the recipe of
|
|
171
|
+
[`recipes/pdf-discounts/`](recipes/pdf-discounts), a copy of the Stellantis example:
|
|
172
|
+
|
|
173
|
+
<!-- capture:pdf-discounts screenshot alt=Page_1_of_the_ENPAM_sheet,_every_cell_readPdf_found_outlined,_header_rows_filled -->
|
|
174
|
+

|
|
175
|
+
<!-- /capture -->
|
|
176
|
+
|
|
177
|
+
<!-- capture:pdf-discounts summary -->
|
|
178
|
+
```text
|
|
179
|
+
stellantis-it: 147 emitted, 0 rejected, 0 duplicates, 1 pages
|
|
180
|
+
```
|
|
181
|
+
<!-- /capture -->
|
|
182
|
+
|
|
183
|
+
### 2.1 What pdf.js gives, and what is kept
|
|
184
|
+
|
|
185
|
+
`readPdf` ([`read-pdf.client.ts:20-52`](../../packages/core/src/pdf-document/read-pdf.client.ts#L20-L52)):
|
|
186
|
+
|
|
187
|
+
1. Imports `pdfjs-dist/legacy/build/pdf.mjs` (pdf.js 6.x) on first use.
|
|
188
|
+
2. Calls `getDocument` with a **copy** of the bytes (pdf.js takes ownership of the buffer it gets, and refuses a
|
|
189
|
+
Node `Buffer`) and `verbosity: 0`, `disableFontFace: true`, `useSystemFonts: false`, `stopAtErrors: true`.
|
|
190
|
+
Nothing is rendered, no font is loaded, no script runs. A load failure becomes
|
|
191
|
+
`PdfReadError("<source>: not a readable PDF (<pdf.js message>)")`. No password is ever passed, so a PDF
|
|
192
|
+
that needs one to open fails here (one encrypted with an owner password only opens).
|
|
193
|
+
3. For each page, `getViewport({ scale: 1 })` gives its width and height in points, and `getTextContent()` its
|
|
194
|
+
text items. Items without a `str` (marked-content markers) are dropped.
|
|
195
|
+
4. Each item becomes a **run** (`runOf`, `:48-52`):
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
const [a, b, c, d, x, y] = item.transform
|
|
199
|
+
return { x, y, width: item.width, height: item.height > 0 ? item.height : Math.hypot(c, d) || Math.hypot(a, b), text: item.str }
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`x` and `y` are the text's origin on its **baseline**, in PDF user space: points from the bottom-left
|
|
203
|
+
corner, `y` growing upwards. `height` is the font size (the matrix's vertical scale when pdf.js reports 0).
|
|
204
|
+
The font name, the direction (`dir`), the rotation part of the matrix, colours and every drawing are
|
|
205
|
+
**not** kept: the reader works from text positions alone, and never sees table ruling.
|
|
206
|
+
5. The page's runs go through `assembleRows`. If **every** page ends with no row, the read fails with
|
|
207
|
+
`no page has a text layer (a scanned PDF? OCR is not supported)` (`:40`). A PDF where only some pages are
|
|
208
|
+
scans reads, and those pages have no rows.
|
|
209
|
+
|
|
210
|
+
The result, `{ kind: 'pdf', pages: [{ number, width, height, rows: [{ top, bottom, cells, text }] }] }`, is
|
|
211
|
+
what `jsonpath` queries. A cell is `{ x, y, width, height, text }`; a row's `top` is its highest `y + height`,
|
|
212
|
+
its `bottom` its lowest baseline, and its `text` the cells joined by a tab.
|
|
213
|
+
|
|
214
|
+
### 2.2 Runs to cells
|
|
215
|
+
|
|
216
|
+
A run is whatever pdf.js emitted as one string: a word, part of a word, a whole line. A cell is text that
|
|
217
|
+
reads as one unit. `joinCells`
|
|
218
|
+
([`row-assembly.algorithm.ts:58-79`](../../packages/core/src/pdf-document/row-assembly.algorithm.ts#L58-L79)):
|
|
219
|
+
|
|
220
|
+
1. **Drop whitespace runs.** pdf.js emits the gap between two table columns as one wide `" "` run, which would
|
|
221
|
+
bridge the columns.
|
|
222
|
+
2. **Sort** the runs: two runs whose baselines are within `SAME_BASELINE × max(heights, 1)` compare left to right;
|
|
223
|
+
otherwise the higher one comes first. (This pairwise tolerance is not a strict total order; for the
|
|
224
|
+
gently wandering baselines of real documents it gives lines top to bottom, each left to right.)
|
|
225
|
+
3. **Join or start.** For each run, find the most recent cell on the same baseline (`|Δy| ≤ 0.2 × size`). With
|
|
226
|
+
`size = max(run height, cell height, 1)` and `gap = run.x − (cell.x + cell.width)`:
|
|
227
|
+
- if `gap ≤ JOIN_GAP × size` and `gap > −size` (an overlap of less than one font size is tolerated), the run
|
|
228
|
+
is appended to the cell. A space goes between them when `gap > SPACE_GAP × size` and neither side already
|
|
229
|
+
has whitespace at the join. The cell keeps its first run's origin, its width grows to the run's right
|
|
230
|
+
edge, its height becomes the larger of the two;
|
|
231
|
+
- otherwise the run starts a new cell.
|
|
232
|
+
4. **Trim** every cell and drop the empty ones.
|
|
233
|
+
|
|
234
|
+
A unit test pins it: `PANDA` and `(model 319)` 2 pt apart at 7 pt become `PANDA (model 319)`, since
|
|
235
|
+
2 ≤ 0.35 × 7 = 2.45 joins them and 2 > 0.1 × 7 = 0.7 puts a space between
|
|
236
|
+
([`row-assembly.algorithm.test.ts:6`](../../packages/core/src/pdf-document/row-assembly.algorithm.test.ts#L6)).
|
|
237
|
+
|
|
238
|
+
### 2.3 Cells to rows: overlap, not baselines
|
|
239
|
+
|
|
240
|
+
`rowsOfCells` (`:36-56`) sorts the cells by their vertical middle (`y + height / 2`), top first, then by `x`,
|
|
241
|
+
and walks them keeping one current row. A cell joins the current row when
|
|
242
|
+
|
|
243
|
+
```text
|
|
244
|
+
overlap = min(cell.y + cell.height, row.top) − max(cell.y, row.bottom) ≥ ROW_OVERLAP × cell.height
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
and the row's extent grows to cover it; otherwise the cell starts a new row. A cell is only compared with the
|
|
248
|
+
**current** row, never with an earlier one. Each row's cells are then sorted left to right (`rowOf`, `:81-90`).
|
|
249
|
+
|
|
250
|
+
Why overlap and not equal baselines (the doc comment, `:15-19`): *"a table that centres its cells vertically puts a
|
|
251
|
+
one-line value a few points above or below its two-line label, and a row built from equal baselines would pair
|
|
252
|
+
the value with the wrong label."* The fixture reproduces it: `MINI (model 103)` sits on baseline 759 and its
|
|
253
|
+
`0,0%` on 762, 3 pt higher. At 7 pt the same-baseline tolerance is 1.4 pt, so the two are on different lines;
|
|
254
|
+
but the label spans 759–766 and the value 762–769, an overlap of 4 pt against the 2.8 pt required, so they
|
|
255
|
+
share a row. In the figure below, `MINI` and `0,0%` are outlined in the same colour.
|
|
256
|
+
|
|
257
|
+
Overlap has a limit: a cell only 40% inside a row's band joins it. On the ENPAM sheet, the Alfa Romeo table
|
|
258
|
+
prints `620 - Giulia` at y 769.9, vertically centred between the two lines of its excluded versions and of its
|
|
259
|
+
extra incentive (y 776.3 and 763.4, see [§2.9](#29-regrouping-wrapped-cells)). The name overlaps the line above
|
|
260
|
+
by 3.7 pt, short of the 4.0 pt that 10 pt text needs, so the three lines stay three rows, and the table reader
|
|
261
|
+
regroups them.
|
|
262
|
+
|
|
263
|
+
### 2.4 The constants, in points
|
|
264
|
+
|
|
265
|
+
All are shares of the font size or cell height, except the last two, which are fixed. The fixture is 7 pt, ENPAM's
|
|
266
|
+
sheet about 10 pt (pdf.js reports 9.97).
|
|
267
|
+
|
|
268
|
+
| Constant | Where | Value | 7 pt | 9 pt | 10 pt | What it decides |
|
|
269
|
+
|---|---|---|---|---|---|---|
|
|
270
|
+
| `SAME_BASELINE` | `row-assembly.algorithm.ts:8` | 0.2 × size | 1.4 | 1.8 | 2.0 | Two runs are on one line. |
|
|
271
|
+
| `JOIN_GAP` | `:4` | 0.35 × size | 2.45 | 3.15 | 3.5 | Two runs on a line join into one cell. |
|
|
272
|
+
| `SPACE_GAP` | `:6` | 0.1 × size | 0.7 | 0.9 | 1.0 | A joined gap wider than this gets a space. |
|
|
273
|
+
| overlap allowed on join | `:68` | 1 × size | 7 | 9 | 10 | A run may start this far inside the previous one. |
|
|
274
|
+
| `ROW_OVERLAP` | `:10` | 0.4 × cell height | 2.8 | 3.6 | 4.0 | A cell joins the current row. |
|
|
275
|
+
| band tolerance | `pdf-table.algorithm.ts:179` | max(3, 0.6 × median cell height) | 4.2 | 5.4 | 6.0 | Left edges closer than this to a band's start are one column. |
|
|
276
|
+
| band slack | `:186`, `:243` | 0.5 pt | | | | A cell belongs to a band whose start is at most 0.5 pt right of it. |
|
|
277
|
+
| `TIE` | `:263` | 1 pt | | | | Two anchors this close to a line are equally near. |
|
|
278
|
+
|
|
279
|
+
### 2.5 Finding a table
|
|
280
|
+
|
|
281
|
+
A `table` extract becomes a query `{ header, until, columns, align }`
|
|
282
|
+
([`pdf-table.algorithm.ts:5-14`](../../packages/core/src/pdf-document/pdf-table.algorithm.ts#L5-L14)). `findTables`
|
|
283
|
+
(`:52-63`) works **one page at a time**:
|
|
284
|
+
|
|
285
|
+
1. Every row whose cells, **joined by single spaces**, match `header` (the step's `selector`) starts a table.
|
|
286
|
+
(`regex` sees the same row with tabs between the cells: a `selector` copied from `regex` output needs spaces.)
|
|
287
|
+
2. The table's body is the rows after the header, up to the next row that matches `header` **on the same page**,
|
|
288
|
+
or the page's end.
|
|
289
|
+
3. `until` cuts the body at the first row it matches; that row is left out (`bodyOf`, `:66-70`).
|
|
290
|
+
4. Header cells that overlap horizontally merge into one, their texts joined by a space (`headerCells`,
|
|
291
|
+
`:161-174`): a header written on two lines that ended up in one row.
|
|
292
|
+
|
|
293
|
+
The result is one `{ page, title, header, rows }` per match. `title` is the first header cell's text
|
|
294
|
+
(`MODELLI FIAT`), which the Stellantis mapping reads the brand from.
|
|
295
|
+
|
|
296
|
+
Because the search is per page, **a table never continues across a page break**. A table repeated under its
|
|
297
|
+
header on every page gives one table per page, which `many: true` and a `forEach` concatenate; a continuation
|
|
298
|
+
page without the header is not part of any table.
|
|
299
|
+
|
|
300
|
+
`until` matters more than it looks. The sheet ends each brand's table with a note:
|
|
301
|
+
|
|
302
|
+
<!-- capture:doc-pdf-enpam screenshot n=2 alt=The_end_of_the_Fiat_table:_a_note_row,_then_the_Abarth_header -->
|
|
303
|
+

|
|
304
|
+
<!-- /capture -->
|
|
305
|
+
|
|
306
|
+
The note is one wide cell starting at x 85.2, inside the model column. Without `until`, it would be a line with
|
|
307
|
+
a name and no value, and [§2.9](#29-regrouping-wrapped-cells) would glue it to the nearest row: `Topolino`'s
|
|
308
|
+
model would end with *NOTA BENE: Tutti i telai…*. The fixture shows it. Its recipe reads the four tables twice,
|
|
309
|
+
with `until: "^(NOTE|\\*)"` and without. The second pass repeats ten rows (the key drops them as duplicates)
|
|
310
|
+
and adds two:
|
|
311
|
+
|
|
312
|
+
<!-- capture:doc-pdf-fixture record record=12 -->
|
|
313
|
+
```json
|
|
314
|
+
{
|
|
315
|
+
"table": "MODELS ALPHA",
|
|
316
|
+
"model": "COUPE (model 105) NOTE: invoices dated before 01/07/2025 are excluded",
|
|
317
|
+
"discount": "11,0%",
|
|
318
|
+
"excluded": null,
|
|
319
|
+
"extra": null
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
<!-- /capture -->
|
|
323
|
+
|
|
324
|
+
<!-- capture:doc-pdf-fixture record record=13 -->
|
|
325
|
+
```json
|
|
326
|
+
{
|
|
327
|
+
"table": "MODELS GAMMA",
|
|
328
|
+
"model": "G3 BEV *Discounts apply to the base list price",
|
|
329
|
+
"discount": "5,0%",
|
|
330
|
+
"excluded": null,
|
|
331
|
+
"extra": "+ Eu 1000 trade-in _ Eu 1500 stock until 30/09/26"
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
<!-- /capture -->
|
|
335
|
+
|
|
336
|
+
<!-- capture:doc-pdf-fixture summary -->
|
|
337
|
+
```text
|
|
338
|
+
fixture-tables: 14 emitted, 0 rejected, 10 duplicates, 1 pages
|
|
339
|
+
```
|
|
340
|
+
<!-- /capture -->
|
|
341
|
+
|
|
342
|
+
A **narrow selector** needs the same care. The [`doc-pdf-enpam`](recipes/doc-pdf-enpam) recipe reads four
|
|
343
|
+
tables only, `^MODELLI (ALFA ROMEO|JEEP|FIAT PROFESSIONAL \(OFF|DS)\b`. A body ends at the next row the
|
|
344
|
+
selector matches, so the DS table, whose next header is Opel's, would read on through the whole Opel table.
|
|
345
|
+
Its `until` therefore adds `MODELLI`: `^(NOTA BENE|N\.B\.|\*Si applicano|MODELLI)`.
|
|
346
|
+
|
|
347
|
+
### 2.6 Columns: bands from the body's left edges
|
|
348
|
+
|
|
349
|
+
A header is a poor guide to where its column is. On this sheet every header is centred over its column, and the
|
|
350
|
+
column's cells are left-aligned:
|
|
351
|
+
|
|
352
|
+
<!-- capture:doc-pdf-enpam screenshot n=1 alt=The_Fiat_table:_headers_centred,_cells_left-aligned -->
|
|
353
|
+

|
|
354
|
+
<!-- /capture -->
|
|
355
|
+
|
|
356
|
+
`MODELLI FIAT` spans x 187.6–245.8; the model names start at 85.4. `Azione Extra *` spans 717.9–776.8; the extras
|
|
357
|
+
start at 572.5, 145 points to its left, closer to the end of `Versioni escluse/note` (547.7) than to their own
|
|
358
|
+
header. The first four Fiat records, read by the full Stellantis recipe:
|
|
359
|
+
|
|
360
|
+
<!-- capture:pdf-discounts records n=4 -->
|
|
361
|
+
```json
|
|
362
|
+
{"month":"2026-09","brand":"FIAT","channel":null,"model":"PANDA (modello 319-390)","discountPercent":22,"excludedVersions":null,"extraIncentive":"+2% premio targa","sheet":"https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"}
|
|
363
|
+
{"month":"2026-09","brand":"FIAT","channel":null,"model":"GRANDE PANDA (modello 325) con data fattura fino al 21-12-25","discountPercent":5,"excludedVersions":null,"extraIncentive":"+3% premio targa (esclusi 325.1HK-1HL-1MK)","sheet":"https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"}
|
|
364
|
+
{"month":"2026-09","brand":"FIAT","channel":null,"model":"GRANDE PANDA (modello 325) con data fattura dal 22-12-25","discountPercent":8,"excludedVersions":null,"extraIncentive":"+3% premio targa (esclusi 325.1HK-1HL-1MK)","sheet":"https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"}
|
|
365
|
+
{"month":"2026-09","brand":"FIAT","channel":null,"model":"500 Hybrid (mod. 302)","discountPercent":12,"excludedVersions":"302.E14.0","extraIncentive":"+2% premio targa","sheet":"https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"}
|
|
366
|
+
```
|
|
367
|
+
<!-- /capture -->
|
|
368
|
+
|
|
369
|
+
Placed by distance from the header cells, the extras would land under `Versioni escluse/note`, the header
|
|
370
|
+
they are nearest. So columns come from the body (`bandsOf`, `:176-193`):
|
|
371
|
+
|
|
372
|
+
1. Take every cell of every body row.
|
|
373
|
+
2. `tolerance = max(3, median cell height × 0.6)`: 6.0 pt here.
|
|
374
|
+
3. Sort the cells' left edges. Walk them: an edge more than `tolerance` right of the **current band's start**
|
|
375
|
+
opens a new band. (Measured from the start, not from the previous edge, so a slow drift of edges cannot chain
|
|
376
|
+
into one wide band.)
|
|
377
|
+
4. A band spans from its start to the furthest right edge of its cells, capped at the next band's start: *"a band
|
|
378
|
+
spans what its cells cover, not the gap up to the next band"*. A cell belongs to a band when
|
|
379
|
+
`start − 0.5 ≤ x < next start − 0.5`.
|
|
380
|
+
|
|
381
|
+
For the Fiat table the left edges are 85.4, 380.0, 382.6, 478.1, 487.0 and 572.5:
|
|
382
|
+
|
|
383
|
+
| Band | Cells starting at | Span |
|
|
384
|
+
|---|---|---|
|
|
385
|
+
| 1 | 85.4 (model names) | 85.4–351.3 |
|
|
386
|
+
| 2 | 380.0 and 382.6 (`12,0%` and `5,0%`: centred, so the shorter starts 2.6 pt further right) | 380.0–404.8 |
|
|
387
|
+
| 3 | 478.1 (`354 con opt J4F`) | 478.1–487.0 |
|
|
388
|
+
| 4 | 487.0 (`302.E14.0`) | 487.0–519.2 |
|
|
389
|
+
| 5 | 572.5 (the extras) | 572.5–755.5 |
|
|
390
|
+
|
|
391
|
+
The two discounts fall in one band because 2.6 < 6.0. The two excluded-version notes do not: they are centred
|
|
392
|
+
too, and 8.9 pt apart. Five bands for four headers: the next step decides which bands share a header.
|
|
393
|
+
|
|
394
|
+
### 2.7 Mapping bands to headers
|
|
395
|
+
|
|
396
|
+
`assignColumns` (`:207-237`) maps the bands to header cells **monotonically**: left to right, a band never maps
|
|
397
|
+
to a header left of the previous band's. Columns do not cross, and this rules out the nonsense mappings a
|
|
398
|
+
per-band "nearest header" would allow. Among the monotone mappings it picks, in order of priority:
|
|
399
|
+
|
|
400
|
+
1. **the one using the most headers**, so a table with as many bands as headers maps one to one;
|
|
401
|
+
2. **the one with the highest summed affinity**, where a band's affinity for a header is their horizontal
|
|
402
|
+
overlap in points when they overlap, and minus their distance when they do not (`:209-213`).
|
|
403
|
+
|
|
404
|
+
It is a dynamic programme over (band, header) pairs. `table[i][h]` holds the best score for bands `0…i` with
|
|
405
|
+
band `i` on header `h`, reached from any `table[i−1][h′]` with `h′ ≤ h`; moving to a new header adds one to
|
|
406
|
+
`used`. The last row's best cell is traced back through `previous` to give every band its header (`:228-236`).
|
|
407
|
+
For *b* bands and *n* headers the work is O(*b* · *n*²), a few hundred steps for a real table.
|
|
408
|
+
|
|
409
|
+
For the Fiat table, the programme gives bands 1 → `MODELLI FIAT` (overlap 58.2), 2 → `Sconto %*` (24.8), 3 and
|
|
410
|
+
4 → `Versioni escluse/note` (8.9 and 32.2), and 5 → `Azione Extra *` (37.6). Band 5 overlaps its header by only
|
|
411
|
+
the 37.6 pt between 717.9 and 755.5, but the first priority settles it: with band 5 anywhere else, a header
|
|
412
|
+
would go unused.
|
|
413
|
+
The freedom left after "use every header" is exactly which neighbours share one: two bands under one header (a
|
|
414
|
+
column whose cells start at two edges, as here), or one header over two columns.
|
|
415
|
+
|
|
416
|
+
The fixture's DELTA table has the second case, a header `(PROMO ONLY AT DEALERS)` over the discount column and
|
|
417
|
+
a column of `M1/M2` codes; the two bands share it and the first row reads `18,0% M1/M2`. ENPAM's off-line Fiat
|
|
418
|
+
Professional table has the variant the Stellantis recipe matches with `"discount": "^(Sconto|\\(PROMO)"`:
|
|
419
|
+
|
|
420
|
+
<!-- capture:doc-pdf-enpam screenshot n=5 alt=The_(PROMO_VALIDA…)_header,_over_the_discount_column_and_the_empty_notes_column -->
|
|
421
|
+

|
|
422
|
+
<!-- /capture -->
|
|
423
|
+
|
|
424
|
+
Three headers here, and three bands: 85.4–251.7, 379.8–404.9 and 572.5–744.5. `(PROMO VALIDA SOLO IN
|
|
425
|
+
CONCESSIONARIA off-line)` spans 350.9–568.7, over the discounts and the empty notes column. The third band is
|
|
426
|
+
3.8 pt right of that header's end and overlaps `Azione Extra *` by 26.6 pt, so it maps to the extras. The table
|
|
427
|
+
as the step binds it, rows cut short:
|
|
428
|
+
|
|
429
|
+
<!-- capture:doc-pdf-enpam scope ids=table record=17 -->
|
|
430
|
+
```json
|
|
431
|
+
{
|
|
432
|
+
"table": {
|
|
433
|
+
"page": 1,
|
|
434
|
+
"title": "MODELLI FIAT PROFESSIONAL (OFF LINE)",
|
|
435
|
+
"header": [
|
|
436
|
+
"MODELLI FIAT PROFESSIONAL (OFF LINE)",
|
|
437
|
+
"(PROMO VALIDA SOLO IN CONCESSIONARIA off-line)",
|
|
438
|
+
"Azione Extra *"
|
|
439
|
+
],
|
|
440
|
+
"rows": [
|
|
441
|
+
{
|
|
442
|
+
"model": "PANDA VAN - 590 serie 0-1",
|
|
443
|
+
"discount": "8,0%",
|
|
444
|
+
"extra": ""
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
"model": "PANDA VAN - 519 serie 9",
|
|
448
|
+
"discount": "8,0%",
|
|
449
|
+
"extra": ""
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
"model": "DOBLO' VAN ICE - 510 serie 1",
|
|
453
|
+
"discount": "12,0%",
|
|
454
|
+
"extra": "Extra 2% in caso di permuta/ rottamazione"
|
|
455
|
+
},
|
|
456
|
+
"… 10 more"
|
|
457
|
+
]
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
<!-- /capture -->
|
|
462
|
+
|
|
463
|
+
The header has no `Versioni…` cell, so the `excluded` pattern of `columns` matches nothing and rows have no
|
|
464
|
+
`excluded` key; the mapping's `default` turns the missing value into `null`:
|
|
465
|
+
|
|
466
|
+
<!-- capture:doc-pdf-enpam record record=19 -->
|
|
467
|
+
```json
|
|
468
|
+
{
|
|
469
|
+
"month": "2026-09",
|
|
470
|
+
"brand": "FIAT PROFESSIONAL",
|
|
471
|
+
"channel": "offline",
|
|
472
|
+
"model": "DOBLO' VAN ICE - 510 serie 1",
|
|
473
|
+
"discountPercent": 12,
|
|
474
|
+
"excludedVersions": null,
|
|
475
|
+
"extraIncentive": "Extra 2% in caso di permuta/ rottamazione",
|
|
476
|
+
"sheet": "https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
<!-- /capture -->
|
|
480
|
+
|
|
481
|
+
### 2.8 Placing values
|
|
482
|
+
|
|
483
|
+
With the bands mapped, every body line becomes one value per header (`valuesOf`, `:239-249`). Its cells, taken
|
|
484
|
+
top to bottom then left to right, go to the **last band whose start is ≤ cell.x + 0.5**, or to the first band
|
|
485
|
+
when none is; pieces landing in the same column on one line are joined by a space. A cell left of every band
|
|
486
|
+
(impossible for body cells, which made the bands) would land in the first column. A cell that spans two columns
|
|
487
|
+
in the PDF (one long string) lands wholly in the column its left edge is in.
|
|
488
|
+
|
|
489
|
+
### 2.9 Regrouping wrapped cells
|
|
490
|
+
|
|
491
|
+
A row of the table is often several lines of the page: a long name wraps, a note runs onto a second line, a list
|
|
492
|
+
of versions sits above and below its row. After §2.8 each body line is `{ row, values[] }`, and `groupLines`
|
|
493
|
+
(`:105-124`) rebuilds table rows from them. A line is **named** when it has a first-column value, and **valued**
|
|
494
|
+
when any other column has one.
|
|
495
|
+
|
|
496
|
+
**1. Anchors.** A line anchors a table row when it is named and valued, or when it is *not* named but has a
|
|
497
|
+
value in the first value column (`values[1]`): the middle line of a name wrapped around centred values. If no
|
|
498
|
+
line qualifies, every named line is an anchor; if none is named either, the table has no rows (`:108-110`).
|
|
499
|
+
|
|
500
|
+
**2. Alignment.** `align` says where a row's values sit against its wrapped cell: `top`, `center`, `bottom`,
|
|
501
|
+
or `auto` (the default). `auto` becomes `center` when some anchor has **no name**, since only a centred table
|
|
502
|
+
produces a value line without a name (`:112`); otherwise `auto` places each line by distance.
|
|
503
|
+
|
|
504
|
+
**3. Centred tables: sharing wrapped names evenly** (`splitNamesEvenly`, `:131-144`). The named lines that are
|
|
505
|
+
not anchors are dealt out in page order: those above the first anchor go to it; between two anchors, the upper
|
|
506
|
+
one takes as many lines below it as it took above it, and the lower one takes the rest; the last anchor takes
|
|
507
|
+
everything after it. A name wrapped over three lines around its values ends up whole.
|
|
508
|
+
|
|
509
|
+
**4. Every other line joins an anchor** (`ownerOf`, `:146-151`):
|
|
510
|
+
|
|
511
|
+
- `top`: the last anchor at or above it (by `bottom`), else the first anchor;
|
|
512
|
+
- `bottom`: the first anchor at or below it, else the last;
|
|
513
|
+
- `auto` and `center`: the **nearest** anchor by vertical gap, `max(0, anchor.bottom − line.top, line.bottom − anchor.top)`
|
|
514
|
+
(`nearest`, `:270-282`). **A tie**, two gaps within `TIE` = 1 pt of each other, **goes to the anchor below**:
|
|
515
|
+
*"text reads top down, so a wrapped cell's first line comes before the row it belongs to."*
|
|
516
|
+
|
|
517
|
+
**5. Nameless anchors.** An anchor whose group still has no named line is a value spilling out of the row next to
|
|
518
|
+
it; its lines merge into the nearest kept anchor (`:117-121`).
|
|
519
|
+
|
|
520
|
+
**6. Joining.** Each group's lines are sorted top to bottom, and each column's non-empty pieces joined by a
|
|
521
|
+
space (`joinLines`, `:154-158`).
|
|
522
|
+
|
|
523
|
+
The fixture has the textbook cases: `SEDAN`'s excluded versions on the line above and the line below
|
|
524
|
+
(`Special 100 edition` · `104.8RU-Top` · `Sport 104.LRU`), and `WAGON`'s name wrapped over three lines with its
|
|
525
|
+
values on the middle one, a nameless anchor that switches ALPHA to centred mode:
|
|
526
|
+
|
|
527
|
+
<!-- capture:doc-pdf-fixture screenshot alt=The_fixture's_ALPHA,_BETA_and_DELTA_tables,_each_row_in_one_colour -->
|
|
528
|
+

|
|
529
|
+
<!-- /capture -->
|
|
530
|
+
|
|
531
|
+
<!-- capture:doc-pdf-fixture records n=6 -->
|
|
532
|
+
```json
|
|
533
|
+
{"table":"MODELS ALPHA","model":"CITY (model 101)","discount":"19,0%","excluded":null,"extra":"+3% registration bonus"}
|
|
534
|
+
{"table":"MODELS ALPHA","model":"CITY EV (model 102)","discount":"3,0%","excluded":null,"extra":null}
|
|
535
|
+
{"table":"MODELS ALPHA","model":"MINI (model 103)","discount":"0,0%","excluded":null,"extra":"1000 euro scrappage bonus"}
|
|
536
|
+
{"table":"MODELS ALPHA","model":"SEDAN (model 104)","discount":"16,0%","excluded":"Special 100 edition 104.8RU-Top Sport 104.LRU","extra":null}
|
|
537
|
+
{"table":"MODELS ALPHA","model":"WAGON base series 1 (104.E23 without OPT JFS-JFR)","discount":"12,0%","excluded":null,"extra":"3% stock bonus"}
|
|
538
|
+
{"table":"MODELS ALPHA","model":"COUPE (model 105)","discount":"11,0%","excluded":null,"extra":null}
|
|
539
|
+
```
|
|
540
|
+
<!-- /capture -->
|
|
541
|
+
|
|
542
|
+
`WAGON`'s first line lies between `SEDAN` and the nameless anchor; `SEDAN` took no name line above it, so it takes
|
|
543
|
+
none below, and the anchor gets it. `OPT JFS-JFR)` follows the anchor, which took one line above, so it takes one
|
|
544
|
+
below. `Special 100 edition` is 2 pt from `MINI` and 2 pt from `SEDAN`, a tie, so it goes to `SEDAN`, below.
|
|
545
|
+
BETA's extras band (314.0–355.8) overlaps `Discount %*` (278.0–316.1) by 2.1 pt and is 34 pt short of its own
|
|
546
|
+
header `Extras*` (390): a per-band "most overlap" rule would put the extras under the discount. Three bands and
|
|
547
|
+
three headers map one to one, so they land under `Extras*`.
|
|
548
|
+
|
|
549
|
+
**The Alfa Romeo table**: notes wrapped over two and three lines, the name and the discount centred beside them.
|
|
550
|
+
|
|
551
|
+
<!-- capture:doc-pdf-enpam screenshot n=3 alt=The_Alfa_Romeo_table:_notes_wrapped_around_names_and_discounts_centred_beside_them -->
|
|
552
|
+

|
|
553
|
+
<!-- /capture -->
|
|
554
|
+
|
|
555
|
+
No line here is a nameless anchor, so `auto` places lines by distance. The numbers, from the rows `readPdf`
|
|
556
|
+
built:
|
|
557
|
+
|
|
558
|
+
| Line | Gap to the anchor above | Gap to the anchor below | Joins |
|
|
559
|
+
|---|---|---|---|
|
|
560
|
+
| `3% Premio targa - 5% PREMIO LOYALTY…` | 5.5 (`627 - Junior elettrica`) | 3.1 (`622 - Tonale ICE`) | Tonale ICE |
|
|
561
|
+
| `promozionati` | 3.0 (`622 - Tonale ICE`) | 20.3 (`638 Tonale PHEV`) | Tonale ICE |
|
|
562
|
+
| `Quadrifoglio Super Sport 620.LRU e` · `5% PREMIO LOYALTY…Alfa` | 27.3 (`638 Tonale PHEV`) | 0.0 (`620 - Giulia`) | Giulia |
|
|
563
|
+
| `QV Ultima 620.XRU` · `Romeo) cumula solo con il tan 4,75%` | 0.0 (`620 - Giulia`) | 25.6 (`630 - Stelvio`) | Giulia |
|
|
564
|
+
|
|
565
|
+
<!-- capture:doc-pdf-enpam records n=6 -->
|
|
566
|
+
```json
|
|
567
|
+
{"month":"2026-09","brand":"ALFA ROMEO","channel":null,"model":"626 - Junior Ibrida serie 0-1","discountPercent":14,"excludedVersions":null,"extraIncentive":"2% PREMIO LOYALTY / PERMUTA ROTTAMAZIONE (solo con P/R di vetture Alfa Romeo)","sheet":"https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"}
|
|
568
|
+
{"month":"2026-09","brand":"ALFA ROMEO","channel":null,"model":"627 - Junior elettrica serie 0-1","discountPercent":11,"excludedVersions":null,"extraIncentive":null,"sheet":"https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"}
|
|
569
|
+
{"month":"2026-09","brand":"ALFA ROMEO","channel":null,"model":"622 - Tonale ICE serie 1-2-3","discountPercent":13,"excludedVersions":null,"extraIncentive":"3% Premio targa - 5% PREMIO LOYALTY / PERMUTA ROTTAMAZIONE (solo con P/R di vetture Alfa Romeo) Su Tonale il premio Loyalty è alternativo ai finanziamenti pr…","sheet":"https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"}
|
|
570
|
+
{"month":"2026-09","brand":"ALFA ROMEO","channel":null,"model":"638 Tonale PHEV serie 1-2- 3","discountPercent":18,"excludedVersions":null,"extraIncentive":null,"sheet":"https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"}
|
|
571
|
+
{"month":"2026-09","brand":"ALFA ROMEO","channel":null,"model":"620 - Giulia","discountPercent":18,"excludedVersions":"Quadrifoglio Super Sport 620.LRU e QV Ultima 620.XRU","extraIncentive":"5% PREMIO LOYALTY / PERMUTA ROTTAMAZIONE (solo con P/R di vetture Alfa Romeo) cumula solo con il tan 4,75%","sheet":"https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"}
|
|
572
|
+
{"month":"2026-09","brand":"ALFA ROMEO","channel":null,"model":"630 - Stelvio","discountPercent":19,"excludedVersions":"Quadrifoglio Super Sport 630.LAU e QV Ultima 630.XAU","extraIncentive":"'5% PREMIO LOYALTY / PERMUTA ROTTAMAZIONE (solo con P/R di vetture Alfa Romeo) cumula solo con il tan 4,75%","sheet":"https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"}
|
|
573
|
+
```
|
|
574
|
+
<!-- /capture -->
|
|
575
|
+
|
|
576
|
+
**The Jeep table**: a nameless anchor, so the whole table is read in centred mode.
|
|
577
|
+
|
|
578
|
+
<!-- capture:doc-pdf-enpam screenshot n=4 alt=The_Jeep_table:_420_AVENGER_BEV's_discount_sits_on_the_line_below_its_name -->
|
|
579
|
+

|
|
580
|
+
<!-- /capture -->
|
|
581
|
+
|
|
582
|
+
`420 AVENGER BEV` (baseline 585.9) and its `5,0%` (578.7) are 7.2 pt apart, with the two lines of its extra at
|
|
583
|
+
592.4 and 579.3: three rows, since each pair overlaps by 3.4 or 3.5 pt, short of 4.0. The `5,0%` line has a
|
|
584
|
+
discount and no name, so it is an anchor, and `auto` becomes `center`. The name is a named line between the
|
|
585
|
+
anchors `619 AVENGER serie 4…` and `5,0%`; the upper one took no name line above it, so the name goes to the
|
|
586
|
+
lower one. The extra's first line is 4.9 pt from the anchor above and 3.1 pt from `5,0%`, so it joins too:
|
|
587
|
+
|
|
588
|
+
<!-- capture:doc-pdf-enpam record record=11 -->
|
|
589
|
+
```json
|
|
590
|
+
{
|
|
591
|
+
"month": "2026-09",
|
|
592
|
+
"brand": "JEEP",
|
|
593
|
+
"channel": null,
|
|
594
|
+
"model": "420 AVENGER BEV",
|
|
595
|
+
"discountPercent": 5,
|
|
596
|
+
"excludedVersions": null,
|
|
597
|
+
"extraIncentive": "Premio ocf \"ALL IN\" Eu 5.500 Longitude - Eu 6.000 Altitude - Eu 7.000 Summit e Black ed. - Premio targa \"ALL IN\" Eu 3.000 (iniziativa automatica AJH26823)",
|
|
598
|
+
"sheet": "https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"
|
|
599
|
+
}
|
|
600
|
+
```
|
|
601
|
+
<!-- /capture -->
|
|
602
|
+
|
|
603
|
+
`434 NEW COMPASS BEV` has a name and an extra on one line, so it is an ordinary anchor; the extra's lines above
|
|
604
|
+
(2.4 pt against 6.9) and below (3.1 against 5.7) join it. It has no discount, and the record says so:
|
|
605
|
+
|
|
606
|
+
<!-- capture:doc-pdf-enpam record record=14 -->
|
|
607
|
+
```json
|
|
608
|
+
{
|
|
609
|
+
"month": "2026-09",
|
|
610
|
+
"brand": "JEEP",
|
|
611
|
+
"channel": null,
|
|
612
|
+
"model": "434 NEW COMPASS BEV",
|
|
613
|
+
"discountPercent": null,
|
|
614
|
+
"excludedVersions": null,
|
|
615
|
+
"extraIncentive": "Eu 5000 OCF \"ALL IN\" - Eu 3000 premio targa \"ALL IN\" (su new Compass BEV premio targa alternativo al tan 2,99% e al 3,99-4,99-5,99% E-Drive - su Avenger bev …",
|
|
616
|
+
"sheet": "https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"
|
|
617
|
+
}
|
|
618
|
+
```
|
|
619
|
+
<!-- /capture -->
|
|
620
|
+
|
|
621
|
+
**The DS table** (page 2, 1214 × 1718 points): every extra is two lines, the first **between** two rows.
|
|
622
|
+
|
|
623
|
+
<!-- capture:doc-pdf-enpam screenshot n=6 alt=The_DS_table:_each_extra's_first_line_sits_halfway_between_two_rows -->
|
|
624
|
+

|
|
625
|
+
<!-- /capture -->
|
|
626
|
+
|
|
627
|
+
The first line of `DS 3 (BEV)`'s extra, `+ Eu 800 permuta/rottamazione…`, is 2.7 pt below `DS 3 MHEV` and 2.7 pt
|
|
628
|
+
above `DS 3 (BEV)`. The gaps tie, so the line goes to the anchor below. Without that rule, `DS 3 MHEV` would get
|
|
629
|
+
three lines and `DS 3 (BEV)` half an extra. The fixture's GAMMA table is built to the same pattern
|
|
630
|
+
([`pdf-table.algorithm.test.ts:52`](../../packages/core/src/pdf-document/pdf-table.algorithm.test.ts#L52)).
|
|
631
|
+
|
|
632
|
+
<!-- capture:doc-pdf-enpam record record=30 -->
|
|
633
|
+
```json
|
|
634
|
+
{
|
|
635
|
+
"month": "2026-09",
|
|
636
|
+
"brand": "DS",
|
|
637
|
+
"channel": null,
|
|
638
|
+
"model": "DS 3 MHEV",
|
|
639
|
+
"discountPercent": 7,
|
|
640
|
+
"excludedVersions": null,
|
|
641
|
+
"extraIncentive": "+ Eu 800 permuta/rottamazione (non disponibile su on - line) _ Eu 1500 stock con immatricolazioni entro 30/09/26",
|
|
642
|
+
"sheet": "https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
<!-- /capture -->
|
|
646
|
+
|
|
647
|
+
<!-- capture:doc-pdf-enpam record record=31 -->
|
|
648
|
+
```json
|
|
649
|
+
{
|
|
650
|
+
"month": "2026-09",
|
|
651
|
+
"brand": "DS",
|
|
652
|
+
"channel": null,
|
|
653
|
+
"model": "DS 3 (BEV)",
|
|
654
|
+
"discountPercent": 7,
|
|
655
|
+
"excludedVersions": null,
|
|
656
|
+
"extraIncentive": "+ Eu 800 permuta/rottamazione (non disponibile su on - line) _ Eu 1500 stock con immatricolazioni entro 30/09/26",
|
|
657
|
+
"sheet": "https://www.enpam.it/wp-content/uploads/STELLANTIS-SCONTI-e-cod-promo_mese-09-2026.pdf"
|
|
658
|
+
}
|
|
659
|
+
```
|
|
660
|
+
<!-- /capture -->
|
|
661
|
+
|
|
662
|
+
### 2.10 Naming the columns, and `fillDown`
|
|
663
|
+
|
|
664
|
+
`named` (`:251-260`) keys each row:
|
|
665
|
+
|
|
666
|
+
- **without `columns`**, by the header texts, through `Object.fromEntries`: of two identical header texts, the
|
|
667
|
+
last column wins;
|
|
668
|
+
- **with `columns`**, each output key takes the **first** header whose text matches its pattern. A key whose
|
|
669
|
+
pattern matches no header is absent (the `excluded` above); a header no pattern names is dropped.
|
|
670
|
+
|
|
671
|
+
Every value is a string: a cell's lines joined by spaces, `''` when empty. `fillDown` runs after, on PDF tables
|
|
672
|
+
as on workbooks ([`extract-from-document.use-case.ts:128`](../../packages/core/src/api-steps/extract-from-document.use-case.ts#L128),
|
|
673
|
+
§3.4): a value printed once for a group of rows is copied down to the rows below it that have none.
|
|
674
|
+
|
|
675
|
+
### 2.11 What fails, and why
|
|
676
|
+
|
|
677
|
+
| Case | What happens | Why |
|
|
678
|
+
|---|---|---|
|
|
679
|
+
| A scanned page | No rows on it. If every page is a scan, the read fails. | pdf.js reads the text layer; there is no OCR. |
|
|
680
|
+
| Password-protected PDF | The read fails at load. | No password is passed to `getDocument`. |
|
|
681
|
+
| Rotated or vertical text | Rows and columns come out wrong. | Only the origin and `width`/`height` are kept; every stage assumes horizontal, left-to-right baselines. A page with `/Rotate` reports its rotated size, but the item coordinates are not transformed. |
|
|
682
|
+
| Two columns closer than `JOIN_GAP` | They merge into one cell (3.5 pt at 10 pt). | Nothing but the gap separates two runs on a line. |
|
|
683
|
+
| A column whose left edges spread over more than the tolerance | Several bands; usually harmless, as the monotone mapping gives them to one header (Fiat's excluded versions). A right-aligned number column with widths differing by more than 6 pt splits the same way. | Bands cluster left edges. |
|
|
684
|
+
| Ruled vs unruled tables | No difference. | Drawings are never read. |
|
|
685
|
+
| A cell merged across columns | One cell, landing in the column of its left edge; its header gets the pieces of every band under it (DELTA). | A PDF carries no merge information. |
|
|
686
|
+
| A value merged down several rows | Printed once: one row gets it, or it anchors the rows in centred mode. `fillDown` copies it down. | As above. |
|
|
687
|
+
| A header on two lines that do not overlap | The second line is a body line, and can become a nameless anchor. | Header cells only merge within one row (§2.5). |
|
|
688
|
+
| Two tables side by side | One set of rows across both; a header pattern sees the whole line. | Rows are whole visual lines of the page. |
|
|
689
|
+
| A table continued on the next page | Not joined; a page without the header is not read. | `findTables` works per page. |
|
|
690
|
+
| A note row under the table, no `until` | Glued to the nearest row (§2.5). | A named line without values joins an anchor. |
|
|
691
|
+
|
|
692
|
+
## 3. Spreadsheets and CSV
|
|
693
|
+
|
|
694
|
+
A spreadsheet and a CSV both become a **workbook**: `{ kind: 'workbook', sheets: [{ name, rows, hidden?,
|
|
695
|
+
hiddenRows?, merges? }], csv? }`, cells `string | number | boolean`
|
|
696
|
+
([`workbook-document.model.ts`](../../packages/core/src/workbook-document/workbook-document.model.ts)). A grid
|
|
697
|
+
needs no geometry, so its table reader is much simpler than the PDF one.
|
|
698
|
+
|
|
699
|
+
### 3.1 Text decoding
|
|
700
|
+
|
|
701
|
+
Every text format (CSV, JSON, JSON Lines, HTML, text, YAML, Markdown, XML) goes through `decodeText`
|
|
702
|
+
([`text-decoding.algorithm.ts:25-43`](../../packages/core/src/http-session/text-decoding.algorithm.ts#L25-L43)),
|
|
703
|
+
which tries in order:
|
|
704
|
+
|
|
705
|
+
1. **A byte-order mark**: `EF BB BF` UTF-8, `FF FE` UTF-16LE, `FE FF` UTF-16BE. It wins even over the recipe's
|
|
706
|
+
`encoding`, and is dropped from the text.
|
|
707
|
+
2. **The recipe's `encoding`**, any WHATWG label; an unknown one fails the step.
|
|
708
|
+
3. **The `charset`** of the `Content-Type`; an unknown one is ignored.
|
|
709
|
+
4. **Strict UTF-8**.
|
|
710
|
+
5. When strict UTF-8 fails: if the lenient UTF-8 decoding holds any character above U+007F other than
|
|
711
|
+
U+FFFD, the text *is* UTF-8 with a stray byte, and stays UTF-8 ("a UTF-8 page with one stray byte keeps its
|
|
712
|
+
accents"). Otherwise it is **Windows-1252**, the superset of Latin-1 that Excel's "CSV" export writes on
|
|
713
|
+
Western Windows.
|
|
714
|
+
|
|
715
|
+
There is no UTF-32 and no BOM-less UTF-16 detection.
|
|
716
|
+
|
|
717
|
+
### 3.2 CSV: parsing and the delimiter
|
|
718
|
+
|
|
719
|
+
`parseCsv` ([`csv-parser.algorithm.ts:19-52`](../../packages/core/src/workbook-document/csv-parser.algorithm.ts#L19-L52))
|
|
720
|
+
is RFC 4180, tolerant: a double quote **at the start of a field** opens a quoted field, in which the delimiter and
|
|
721
|
+
line breaks are literal and `""` is a quote; an unterminated quote runs to the end; a quote elsewhere is a
|
|
722
|
+
literal character (`1.0 Hybrid "Cross"`). CRLF, LF and a lone CR end a record. Nothing is trimmed, ragged rows
|
|
723
|
+
stay ragged, a blank line is the row `['']`, and a trailing line break adds no row.
|
|
724
|
+
|
|
725
|
+
`detectDelimiter` (`:65-78`) parses the first 64 KiB with each of `,` `;` tab `|` and scores the non-blank rows
|
|
726
|
+
among the first 100 (the last one dropped when the sample cuts the file): the most common width above 1 (the larger on a tie), and
|
|
727
|
+
`agreeing rows / rows × 1000 + width`. The highest score wins; on equal scores the earlier candidate; with none
|
|
728
|
+
above 0, `,`. The repository's [`listino.csv`](../../packages/core/src/workbook-document/fixtures/listino.csv)
|
|
729
|
+
fixture is Windows-1252 with CRLF, a title line and a blank line above the header, `;` with decimal commas, and a
|
|
730
|
+
quoted field holding `;` and a line break:
|
|
731
|
+
|
|
732
|
+
| Delimiter | Rows | Widths (width: rows) | Score |
|
|
733
|
+
|---|---|---|---|
|
|
734
|
+
| `,` | 8 | 1: 3, 3: 2, 2: 3 | 3/8 × 1000 + 2 = 377.0 |
|
|
735
|
+
| `;` | 7 | 1: 1, 5: 6 | 6/7 × 1000 + 5 = 862.1 |
|
|
736
|
+
| tab, `\|` | 8 | 1: 8 | 0 |
|
|
737
|
+
|
|
738
|
+
With `,`, the quote before `Plus; automatica` is not at the start of a field, so it is literal and the line
|
|
739
|
+
break ends the record: eight rows instead of seven. The title line is outvoted either way. The file has no BOM
|
|
740
|
+
and fails strict UTF-8 (`ë`, `€` and `–` are single bytes), so it decodes as Windows-1252. The step's `id` holds
|
|
741
|
+
the workbook, with what was detected:
|
|
742
|
+
|
|
743
|
+
<!-- capture:doc-csv-listino scope ids=listino -->
|
|
744
|
+
```json
|
|
745
|
+
{
|
|
746
|
+
"listino": {
|
|
747
|
+
"kind": "workbook",
|
|
748
|
+
"sheets": [
|
|
749
|
+
{
|
|
750
|
+
"name": "listino",
|
|
751
|
+
"rows": [
|
|
752
|
+
[
|
|
753
|
+
"Listino prezzi autoveicoli – settembre 2026"
|
|
754
|
+
],
|
|
755
|
+
[
|
|
756
|
+
""
|
|
757
|
+
],
|
|
758
|
+
[
|
|
759
|
+
"Marca",
|
|
760
|
+
"Modello",
|
|
761
|
+
"Versione",
|
|
762
|
+
"… 2 more"
|
|
763
|
+
],
|
|
764
|
+
"… 6 more"
|
|
765
|
+
]
|
|
766
|
+
}
|
|
767
|
+
],
|
|
768
|
+
"csv": {
|
|
769
|
+
"encoding": "windows-1252",
|
|
770
|
+
"delimiter": ";"
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
```
|
|
775
|
+
<!-- /capture -->
|
|
776
|
+
|
|
777
|
+
A CSV is a one-sheet workbook named after the file (`sheetNameOf`, the last path segment without its extension),
|
|
778
|
+
and every cell is a string: numbers are converted in the mapping, with a `locale`.
|
|
779
|
+
|
|
780
|
+
### 3.3 The XLSX reader
|
|
781
|
+
|
|
782
|
+
`@opencraw/office-reader` reads Office Open XML with two dependencies, `fflate` (zip) and `htmlparser2`
|
|
783
|
+
(streaming XML).
|
|
784
|
+
|
|
785
|
+
**The package** ([`ooxml-package.client.ts`](../../packages/office-reader/src/ooxml-package/ooxml-package.client.ts)):
|
|
786
|
+
|
|
787
|
+
- **Refusals first**, each an `OfficeReadError` with a code: an OLE compound file holding an `EncryptionInfo`
|
|
788
|
+
stream is `encrypted`, any other compound file `legacy-format` (`.xls`, `.ppt`, `.doc`); a zip whose `mimetype`
|
|
789
|
+
starts `application/vnd.oasis.opendocument` is `unsupported-format`; bytes that are not a zip are `not-zip`.
|
|
790
|
+
- **Listing without inflating.** The zip's directory is read first; each part is inflated only when asked for.
|
|
791
|
+
Part names are case-folded and backslashes turned to slashes, since OPC names are case-insensitive and some
|
|
792
|
+
writers produce `xl\sharedStrings.xml`.
|
|
793
|
+
- **Limits.** A part may declare at most 256 MiB, all parts read together 512 MiB (`:18-19`), each part
|
|
794
|
+
counted once however often it is read; fflate never inflates past a declared size, so a header that lies
|
|
795
|
+
about a zip bomb gets a truncated part. Every part is located when the package opens, so reading a part
|
|
796
|
+
never walks the zip's directory again.
|
|
797
|
+
- **Streaming XML** (`walkXml`): SAX events, no DOM, no DTD processing, so declared entities are never expanded
|
|
798
|
+
and nothing external is fetched. Relationships are typed by the last segment of their type URI, which is
|
|
799
|
+
what the transitional and strict namespaces share.
|
|
800
|
+
|
|
801
|
+
**The workbook** ([`read-xlsx.use-case.ts`](../../packages/office-reader/src/spreadsheet/read-xlsx.use-case.ts)):
|
|
802
|
+
the main part comes from the root relationship (else `xl/workbook.xml`); `workbook.xml` gives the sheets in
|
|
803
|
+
order, their `hidden` state (`hidden` or `veryHidden`) and `date1904`; shared strings and styles come through
|
|
804
|
+
its relationships; chart, dialog and macro sheets are skipped.
|
|
805
|
+
|
|
806
|
+
**A worksheet** is streamed ([`worksheet.mapper.ts`](../../packages/office-reader/src/spreadsheet/worksheet.mapper.ts)):
|
|
807
|
+
`<row r hidden>` records hidden rows, `<c r t s>` places a cell (the previous position + 1 when `r` is missing),
|
|
808
|
+
`<mergeCell ref>` records a merged range. **Formulas are not evaluated**: `<f>` is ignored and `<v>`, the
|
|
809
|
+
cached result the writing application stored, is read. A formula without a cached value is empty. Hidden
|
|
810
|
+
columns are not recorded, so their cells read like any others.
|
|
811
|
+
|
|
812
|
+
**Values** ([`cell-value.algorithm.ts`](../../packages/office-reader/src/spreadsheet/cell-value.algorithm.ts)):
|
|
813
|
+
`t="s"` is a shared string (rich-text runs concatenated, phonetic guides dropped), `str` and `inlineStr` text,
|
|
814
|
+
`b` a boolean, `e` an error (`#DIV/0!`), `d` an ISO date. A number cell is a date or time when its **number
|
|
815
|
+
format** says so, else a number. The format only decides that: built-in ids 14–17, 22, 27–31, 34–36, 50–54, 57
|
|
816
|
+
and 58 are dates, 18–21, 32, 33, 45–47, 55 and 56 times; a custom code is read from its first section, with
|
|
817
|
+
quoted text, escapes and `[…]` brackets (`[Red]`, `[$-410]`) removed, as a date when `d`, `y` or a month `m`
|
|
818
|
+
remain (an `m` after `h` or `:`, or before `:` or `s`, is minutes), a time for `h` or `s`, and `[h]` elapsed
|
|
819
|
+
time as a number. **Display formats are never applied**: a percentage is `0.125`, a currency cell `15950`.
|
|
820
|
+
A serial becomes a date by `(serial − 25 569) × 86 400 s` from 1970, `+ 1462` days in the 1904 system, and one
|
|
821
|
+
day added to serials 1–59 to undo Lotus 1-2-3's phantom 29 February 1900. Serial 60, that phantom day, has no
|
|
822
|
+
real date: it reads as the text `1900-02-29`, what Excel shows, with its time when it has one.
|
|
823
|
+
|
|
824
|
+
The core adapter ([`read-xlsx.client.ts`](../../packages/core/src/workbook-document/read-xlsx.client.ts)) keeps
|
|
825
|
+
numbers and booleans typed ("`13955.625` is unambiguous; as text, a locale guess could read it as thirteen
|
|
826
|
+
million"), writes dates as ISO text (`2026-06-01` at midnight, `2026-06-01T09:30:00` otherwise, and `12:00:00`
|
|
827
|
+
for a time of day, a serial under 1 in either date system, so a 1904 workbook's `h:mm` cell holding 0.5 is
|
|
828
|
+
`12:00:00` too), errors as their text, and empty cells as `''`.
|
|
829
|
+
|
|
830
|
+
### 3.4 The grid table algorithm
|
|
831
|
+
|
|
832
|
+
`findGridTables` ([`grid-table.algorithm.ts:51-118`](../../packages/core/src/workbook-document/grid-table.algorithm.ts#L51-L118))
|
|
833
|
+
has no geometry: column *i* of a row belongs to header *i*. Per sheet (hidden sheets skipped unless
|
|
834
|
+
`includeHidden`, sheets filtered by `sheet`):
|
|
835
|
+
|
|
836
|
+
1. **Visible rows**: hidden rows are removed, unless `includeHidden`.
|
|
837
|
+
2. **The filled grid**: every merged range's top-left value is copied into each cell it covers (`filledGrid`,
|
|
838
|
+
`:164-178`): a brand merged down its models reads on every row, a group header merged across its sub-columns
|
|
839
|
+
names each of them.
|
|
840
|
+
3. **Header matching reads the raw rows, not the filled grid.** A row's text is its non-empty cells,
|
|
841
|
+
whitespace collapsed, joined by single spaces (`plain`, `:209-211`). A row starts a table when that text is
|
|
842
|
+
non-empty and matches the `selector`. A header merged across columns is therefore matched by its text once,
|
|
843
|
+
not once per covered cell. `until` tests the raw text too.
|
|
844
|
+
4. **The header** is the start row plus the next `headerRows − 1` visible rows.
|
|
845
|
+
5. **The body** is the visible rows after the header, up to the next header start, the first row `until`
|
|
846
|
+
matches, or the sheet's end. Empty rows are skipped, but `until` is tested first, so `"until": "^$"` ends a
|
|
847
|
+
table at the first empty row.
|
|
848
|
+
6. **Column keys** (`columnsOf`, `:126-145`): for each column up to the widest header or body row of the filled
|
|
849
|
+
grid, the distinct cleaned texts of its header rows, joined (`Prezzo` over `Listino` → `Prezzo Listino`). A
|
|
850
|
+
column with no header text but data is keyed by its **letter** (`E`); one with neither is **dropped**; a key
|
|
851
|
+
seen before gets a counter (`Price 2`).
|
|
852
|
+
7. **Values** come from the filled grid; strings are trimmed but keep their inner line breaks, numbers and
|
|
853
|
+
booleans stay typed. `columns` picks, per output key, the first column whose key matches.
|
|
854
|
+
8. **`title`** is the first non-empty cell of the raw header row, and **`fillDown`** (`:70-87`) fills an empty
|
|
855
|
+
value with the last non-empty one above it in the same table.
|
|
856
|
+
|
|
857
|
+
The CSV fixture writes `Fiat` and `Pandina` once over two versions, and ends with a `Totale` line;
|
|
858
|
+
`"fillDown": ["brand", "model"]` and `"until": "^Totale"`:
|
|
859
|
+
|
|
860
|
+
<!-- capture:doc-csv-listino records n=4 -->
|
|
861
|
+
```json
|
|
862
|
+
{"brand":"Fiat","model":"Pandina","version":"1.0 Hybrid \"Cross\"","price":"15.950,00","discount":"12,5"}
|
|
863
|
+
{"brand":"Fiat","model":"Pandina","version":"1.0 Hybrid Icon","price":"16.450,00","discount":"12,5"}
|
|
864
|
+
{"brand":"Citroën","model":"C3","version":"Plus; automatica\nnuova","price":"19.300,00","discount":"8"}
|
|
865
|
+
{"brand":"Peugeot","model":"208","version":"Allure","price":"21.450,00","discount":null}
|
|
866
|
+
```
|
|
867
|
+
<!-- /capture -->
|
|
868
|
+
|
|
869
|
+
The office-reader fixture [`incentivi.xlsx`](../../packages/office-reader/src/spreadsheet/fixtures/incentivi.xlsx)
|
|
870
|
+
has what a spreadsheet adds: a title merged across the sheet (`A1:H1`), a header over two rows with `Prezzo`
|
|
871
|
+
merged over `Listino` and `Netto` (`C3:D3`) and the other headers merged down (`A3:A4`…), a brand merged down
|
|
872
|
+
two models (`A5:A6`), a formula (`C5*(1-E5)`, cached `13955.625`), a percentage, a date and a date-time, booleans,
|
|
873
|
+
`#DIV/0!`, a hidden row holding `secret`, a hidden sheet and a chart sheet. With `"headerRows": 2` and
|
|
874
|
+
`"until": "^Consegna"`, the table the step binds (keys from `columns`):
|
|
875
|
+
|
|
876
|
+
<!-- capture:doc-xlsx-incentivi scope ids=table -->
|
|
877
|
+
```json
|
|
878
|
+
{
|
|
879
|
+
"table": {
|
|
880
|
+
"sheet": "Incentivi giugno",
|
|
881
|
+
"title": "Marca",
|
|
882
|
+
"header": [
|
|
883
|
+
"Marca",
|
|
884
|
+
"Modello",
|
|
885
|
+
"Prezzo Listino",
|
|
886
|
+
"… 5 more"
|
|
887
|
+
],
|
|
888
|
+
"rows": [
|
|
889
|
+
{
|
|
890
|
+
"brand": "Fiat",
|
|
891
|
+
"model": "Pandina",
|
|
892
|
+
"list": 15950,
|
|
893
|
+
"net": 13955.625,
|
|
894
|
+
"discount": 0.125,
|
|
895
|
+
"from": "2026-06-01",
|
|
896
|
+
"active": true,
|
|
897
|
+
"note": "Solo rottamazione"
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
"brand": "Fiat",
|
|
901
|
+
"model": "Pandina Cross",
|
|
902
|
+
"list": 17950,
|
|
903
|
+
"net": 15706.25,
|
|
904
|
+
"discount": 0.125,
|
|
905
|
+
"from": "2026-06-01T09:30:00",
|
|
906
|
+
"active": false,
|
|
907
|
+
"note": "#DIV/0!"
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
"brand": "Jeep",
|
|
911
|
+
"model": "Avenger",
|
|
912
|
+
"list": 24950.5,
|
|
913
|
+
"net": "",
|
|
914
|
+
"discount": "",
|
|
915
|
+
"from": "",
|
|
916
|
+
"active": "",
|
|
917
|
+
"note": ""
|
|
918
|
+
}
|
|
919
|
+
]
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
```
|
|
923
|
+
<!-- /capture -->
|
|
924
|
+
|
|
925
|
+
`Fiat` fills `Pandina Cross` from the merge, the hidden row is gone, and the types survive into the records:
|
|
926
|
+
|
|
927
|
+
<!-- capture:doc-xlsx-incentivi records n=3 -->
|
|
928
|
+
```json
|
|
929
|
+
{"brand":"Fiat","model":"Pandina","listPrice":15950,"netPrice":13955.625,"discount":0.125,"validFrom":"2026-06-01","active":true,"note":"Solo rottamazione"}
|
|
930
|
+
{"brand":"Fiat","model":"Pandina Cross","listPrice":17950,"netPrice":15706.25,"discount":0.125,"validFrom":"2026-06-01T09:30:00","active":false,"note":"#DIV/0!"}
|
|
931
|
+
{"brand":"Jeep","model":"Avenger","listPrice":24950.5,"netPrice":null,"discount":null,"validFrom":null,"active":null,"note":null}
|
|
932
|
+
```
|
|
933
|
+
<!-- /capture -->
|
|
934
|
+
|
|
935
|
+
**A real workbook.** The GSA's FY2026 per diem rates
|
|
936
|
+
([`examples/gsa-per-diem/`](../../examples/gsa-per-diem)) is one sheet of 652 rows: a title in `B1`, the header
|
|
937
|
+
`ID STATE DESTINATION …` in row 2, then a row with no state for the standard rate, which the mapping skips
|
|
938
|
+
(`onMissing: "skip-record"`, reported as rejected). Row 4 carries three cells past the header, the last a
|
|
939
|
+
single space; `clean` makes it `''`, so columns I to K have neither header nor data and are dropped.
|
|
940
|
+
Lodging and meal rates arrive as numbers.
|
|
941
|
+
|
|
942
|
+
<!-- capture:doc-xlsx-gsa records n=3 -->
|
|
943
|
+
```json
|
|
944
|
+
{"rejected":{"field":"state","reason":"missing"}}
|
|
945
|
+
{"state":"AL","destination":"Birmingham","counties":"Jefferson","seasonBegin":"all year","seasonEnd":null,"lodgingUsd":126,"mealsUsd":80}
|
|
946
|
+
{"state":"AL","destination":"Gulf Shores","counties":"Baldwin","seasonBegin":"October 1","seasonEnd":"February 28","lodgingUsd":134,"mealsUsd":74}
|
|
947
|
+
```
|
|
948
|
+
<!-- /capture -->
|
|
949
|
+
|
|
950
|
+
<!-- capture:doc-xlsx-gsa summary -->
|
|
951
|
+
```text
|
|
952
|
+
gsa-per-diem-local: 649 emitted, 1 rejected, 0 duplicates, 1 pages
|
|
953
|
+
```
|
|
954
|
+
<!-- /capture -->
|
|
955
|
+
|
|
956
|
+
What the grid reader does not do: two tables side by side on one sheet read as one wide table (no column range
|
|
957
|
+
can be given); hidden columns are included; values are never formatted.
|
|
958
|
+
|
|
959
|
+
## 4. PowerPoint
|
|
960
|
+
|
|
961
|
+
`readPptx` ([`read-pptx.use-case.ts`](../../packages/office-reader/src/presentation/read-pptx.use-case.ts)) opens
|
|
962
|
+
the package as in §3.3 and reads `presentation.xml`: the slides in **presentation order**, and the slide size
|
|
963
|
+
in EMU, 12 700 per point (default 9 144 000 × 6 858 000, 720 × 540 pt). Each slide
|
|
964
|
+
([`slide.mapper.ts`](../../packages/office-reader/src/presentation/slide.mapper.ts)) is streamed into:
|
|
965
|
+
|
|
966
|
+
- **shapes**, `{ x, y, width, height, text, placeholder? }` in points from the **top-left** corner. A shape's box
|
|
967
|
+
is its own `xfrm`, mapped through every enclosing group (`x′ = off.x + (x − chOff.x) × ext / chExt`, innermost
|
|
968
|
+
group first); failing that, a placeholder inherits its box from the layout (same `idx`, then same type), then
|
|
969
|
+
the master. Paragraphs are joined by `\n`, `<a:br>` is `\n`. Empty shapes and the slide-number, date and
|
|
970
|
+
footer placeholders are dropped. The first `title` or `ctrTitle` placeholder is the slide's `title`. Shapes are
|
|
971
|
+
sorted by `round(y)`, then `x`: reading order.
|
|
972
|
+
- **tables**, each a grid with its merges: `gridSpan` and `rowSpan` record a range from the cell that starts it,
|
|
973
|
+
and the cells an `hMerge` or `vMerge` covers hold `''`.
|
|
974
|
+
- **charts**, read from the values the chart part caches (`c:strCache`, `c:numCache`), so the embedded workbook
|
|
975
|
+
is never opened: the type (the first `…Chart` element of the plot area), the title (not an axis title: its
|
|
976
|
+
rich-text runs once, or, for a title linked to a cell, the cached text, never the `c:f` reference), and each
|
|
977
|
+
series' name, categories and values, `null` for a point left out of the cache. The chart types Office added
|
|
978
|
+
in 2016 (waterfall, treemap, sunburst, histogram, box and whisker, funnel, region map) live in `chartEx`
|
|
979
|
+
parts: their type is the series' `layoutId`, and their data comes from the part's own `cx:chartData`
|
|
980
|
+
(`cx:strDim`, `cx:numDim`).
|
|
981
|
+
- **notes**, the notes slide's body placeholder.
|
|
982
|
+
|
|
983
|
+
SmartArt, text in images and animations are not read. The core adapter keeps typed values, notes and charts
|
|
984
|
+
([`read-pptx.client.ts`](../../packages/core/src/deck-document/read-pptx.client.ts)).
|
|
985
|
+
|
|
986
|
+
**Native tables** go through the grid algorithm of §3.4, one workbook per slide, after the `slide` filter:
|
|
987
|
+
a slide is read when it is visible (or `includeHidden`) and its title matches `slide`
|
|
988
|
+
([`deck-table.algorithm.ts:39-50`](../../packages/core/src/deck-document/deck-table.algorithm.ts#L39-L50)). A
|
|
989
|
+
slide without a title placeholder has the title `''`. The DfE's model board pack
|
|
990
|
+
([`examples/dfe-college-accounts/`](../../examples/dfe-college-accounts)) is 13 slides of 960 × 540 pt;
|
|
991
|
+
`"slide": "^Student Numbers$"` picks slide 6 by its title, and `selector` its table by the header row:
|
|
992
|
+
|
|
993
|
+
<!-- capture:doc-pptx-dfe scope ids=table -->
|
|
994
|
+
```json
|
|
995
|
+
{
|
|
996
|
+
"table": {
|
|
997
|
+
"slide": 6,
|
|
998
|
+
"slideTitle": "Student Numbers",
|
|
999
|
+
"title": "Headcount",
|
|
1000
|
+
"header": [
|
|
1001
|
+
"Headcount",
|
|
1002
|
+
"Full year actuals (last year)",
|
|
1003
|
+
"Actuals (current year)",
|
|
1004
|
+
"… 4 more"
|
|
1005
|
+
],
|
|
1006
|
+
"rows": [
|
|
1007
|
+
{
|
|
1008
|
+
"programme": "16-19 students",
|
|
1009
|
+
"last": "2,820",
|
|
1010
|
+
"current": "2,780",
|
|
1011
|
+
"budget": "2,710",
|
|
1012
|
+
"forecast": "2,790",
|
|
1013
|
+
"rag": "Green",
|
|
1014
|
+
"implications": "Increase in lagged funding in 202X/2Y of circa £350,000"
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
"programme": "ASF (including devolved)",
|
|
1018
|
+
"last": "1,120",
|
|
1019
|
+
"current": "743",
|
|
1020
|
+
"budget": "1,230",
|
|
1021
|
+
"forecast": "1,150",
|
|
1022
|
+
"rag": "Red",
|
|
1023
|
+
"implications": "Forecast shortfall of £139,000 in 202W/2X which is outside tolerances"
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
"programme": "16-18 Apprenticeships",
|
|
1027
|
+
"last": "210",
|
|
1028
|
+
"current": "115",
|
|
1029
|
+
"budget": "136",
|
|
1030
|
+
"forecast": "128",
|
|
1031
|
+
"rag": "Red",
|
|
1032
|
+
"implications": "Forecast shortfall in income of £36,000. Substantial decline from previous year"
|
|
1033
|
+
},
|
|
1034
|
+
"… 4 more"
|
|
1035
|
+
]
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
```
|
|
1039
|
+
<!-- /capture -->
|
|
1040
|
+
|
|
1041
|
+
The header cells hold line breaks (`Full year\nactuals\n(last year)`); keys are cleaned, so the key is
|
|
1042
|
+
`Full year actuals (last year)`. Values keep theirs: `HE: full-time`'s implications read
|
|
1043
|
+
`…£85,000\nSubstantial decline from previous year`. Numbers are text in a slide, converted in the mapping with
|
|
1044
|
+
`"locale": "en-GB"`:
|
|
1045
|
+
|
|
1046
|
+
<!-- capture:doc-pptx-dfe record -->
|
|
1047
|
+
```json
|
|
1048
|
+
{
|
|
1049
|
+
"programme": "16-19 students",
|
|
1050
|
+
"lastYearActual": 2820,
|
|
1051
|
+
"currentActual": 2780,
|
|
1052
|
+
"budget": 2710,
|
|
1053
|
+
"forecast": 2790,
|
|
1054
|
+
"rag": "Green",
|
|
1055
|
+
"implications": "Increase in lagged funding in 202X/2Y of circa £350,000",
|
|
1056
|
+
"slide": 6
|
|
1057
|
+
}
|
|
1058
|
+
```
|
|
1059
|
+
<!-- /capture -->
|
|
1060
|
+
|
|
1061
|
+
The deck's one chart, a waterfall on slide 9, is a `chartEx` part. `readPptx` gives it as
|
|
1062
|
+
`{ type: "waterfall", title: "Income and Expenditure Forecast Variance to Budget (£’000)" }` with one series:
|
|
1063
|
+
the categories `Budget`, `ASF`, `Apps`, `HE`, `Other income`, `Pay`, `Non pay`, `Other`, `Forecast` and the values
|
|
1064
|
+
`-20`, `-139`, `-56`, `-92`, `-69`, `216`, `-110`, `8`, `-262`, which `jsonpath` reads like any other chart's.
|
|
1065
|
+
|
|
1066
|
+
**Text-box grids** read with `shapes: true` (`shapeTables`, `:53-72`). Each selected slide becomes a pseudo-PDF
|
|
1067
|
+
page: each shape a cell, with `y` flipped to the PDF convention (`slide height − y − height`, the box's bottom
|
|
1068
|
+
edge standing in for the baseline), the box's height for the font size, and its whitespace collapsed. Rows come
|
|
1069
|
+
from `rowsOfCells`, the PDF's overlap rule (§2.3), without run joining; then `findTables` runs with `header`,
|
|
1070
|
+
`until`, `columns` and `align`. Everything in §2.5–2.10 applies, with box heights in place of font sizes: 25 pt
|
|
1071
|
+
boxes need 10 pt of overlap to share a row, and their band tolerance is 15 pt. Every shape takes part,
|
|
1072
|
+
the title included, and a very tall box can pull two rows together.
|
|
1073
|
+
|
|
1074
|
+
The office-reader fixture [`incentivi.pptx`](../../packages/office-reader/src/presentation/fixtures/incentivi.pptx)
|
|
1075
|
+
reads four ways in one recipe: a native table under a two-row merged header on slide 1, a chart on slide 3 by
|
|
1076
|
+
`jsonpath`, the notes of slide 1 by `regex` (`Notes: [^\n]*\n(Validi[^\n]*)`), and on slide 2 a grid of text
|
|
1077
|
+
boxes inside a group scaled by the group's transform:
|
|
1078
|
+
|
|
1079
|
+
<!-- capture:doc-pptx-incentivi scope ids=native,chart,validity -->
|
|
1080
|
+
```json
|
|
1081
|
+
{
|
|
1082
|
+
"native": {
|
|
1083
|
+
"slide": 1,
|
|
1084
|
+
"slideTitle": "Incentivi giugno",
|
|
1085
|
+
"title": "Modello",
|
|
1086
|
+
"header": [
|
|
1087
|
+
"Modello",
|
|
1088
|
+
"Prezzo Listino",
|
|
1089
|
+
"Prezzo Netto",
|
|
1090
|
+
"Sconto"
|
|
1091
|
+
],
|
|
1092
|
+
"rows": [
|
|
1093
|
+
{
|
|
1094
|
+
"Modello": "Pandina",
|
|
1095
|
+
"Prezzo Listino": "15.950 €",
|
|
1096
|
+
"Prezzo Netto": "13.955 €",
|
|
1097
|
+
"Sconto": "12,5%"
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
"Modello": "Pandina Cross",
|
|
1101
|
+
"Prezzo Listino": "17.950 €",
|
|
1102
|
+
"Prezzo Netto": "15.706 €",
|
|
1103
|
+
"Sconto": "12,5%"
|
|
1104
|
+
}
|
|
1105
|
+
]
|
|
1106
|
+
},
|
|
1107
|
+
"chart": {
|
|
1108
|
+
"type": "bar",
|
|
1109
|
+
"title": "Immatricolazioni",
|
|
1110
|
+
"series": [
|
|
1111
|
+
{
|
|
1112
|
+
"name": "Pandina",
|
|
1113
|
+
"categories": [
|
|
1114
|
+
"Aprile",
|
|
1115
|
+
"Maggio",
|
|
1116
|
+
"Giugno"
|
|
1117
|
+
],
|
|
1118
|
+
"values": [
|
|
1119
|
+
1200,
|
|
1120
|
+
1350.5,
|
|
1121
|
+
1410
|
|
1122
|
+
]
|
|
1123
|
+
},
|
|
1124
|
+
{
|
|
1125
|
+
"name": "600e",
|
|
1126
|
+
"categories": [
|
|
1127
|
+
"Aprile",
|
|
1128
|
+
"Maggio",
|
|
1129
|
+
"Giugno"
|
|
1130
|
+
],
|
|
1131
|
+
"values": [
|
|
1132
|
+
300,
|
|
1133
|
+
null,
|
|
1134
|
+
410
|
|
1135
|
+
]
|
|
1136
|
+
}
|
|
1137
|
+
]
|
|
1138
|
+
},
|
|
1139
|
+
"validity": "Validi fino al 30 giugno."
|
|
1140
|
+
}
|
|
1141
|
+
```
|
|
1142
|
+
<!-- /capture -->
|
|
1143
|
+
|
|
1144
|
+
<!-- capture:doc-pptx-incentivi records n=2 -->
|
|
1145
|
+
```json
|
|
1146
|
+
{"model":"Avenger","price":"24.950 €","discount":"8%","slide":2}
|
|
1147
|
+
{"model":"Compass","price":"39.900 €","discount":"10%","slide":2}
|
|
1148
|
+
```
|
|
1149
|
+
<!-- /capture -->
|
|
1150
|
+
|
|
1151
|
+
## 5. Word
|
|
1152
|
+
|
|
1153
|
+
A `.docx` is read by `readDocx`
|
|
1154
|
+
([`read-docx.use-case.ts`](../../packages/office-reader/src/document/read-docx.use-case.ts)) into blocks, then
|
|
1155
|
+
turned into **HTML** by the core
|
|
1156
|
+
([`read-docx-html.client.ts`](../../packages/core/src/docx-document/read-docx-html.client.ts)), so `css`,
|
|
1157
|
+
`xpath`, `regex` and `table` all work on it.
|
|
1158
|
+
|
|
1159
|
+
**What is read** ([`body.mapper.ts`](../../packages/office-reader/src/document/body.mapper.ts)):
|
|
1160
|
+
|
|
1161
|
+
- **Paragraphs**: `<w:t>` text, `<w:tab>` as `\t`, `<w:br>` and `<w:cr>` as `\n`, a non-breaking hyphen as `-`.
|
|
1162
|
+
Empty paragraphs are dropped.
|
|
1163
|
+
- **Tracked changes read as accepted**: insertions and `moveTo` are text; deletions and `moveFrom` are skipped.
|
|
1164
|
+
Field codes (`instrText`) are skipped and their displayed result kept; `mc:Fallback` is skipped, so a text box
|
|
1165
|
+
is read once.
|
|
1166
|
+
- **Headings** from the paragraph's outline level, else its style chain (`basedOn`, at most 20 steps): a style
|
|
1167
|
+
*named* `heading N` (the built-in names stay English in every language, so an Italian `Titolo2` is named
|
|
1168
|
+
`heading 2`), `title` as 1, or an outline level. **Lists** from the paragraph's numbering, else its style's;
|
|
1169
|
+
a level is ordered unless its format is `bullet` or `none`. A heading is never a list item.
|
|
1170
|
+
- **Tables**: a cell's paragraphs joined by `\n`; `gridSpan` pads the row and records a range; `vMerge`
|
|
1171
|
+
`restart`…`continue` records a vertical range and leaves `''` in the covered cells; `gridBefore` pads the
|
|
1172
|
+
row's start. A nested table becomes its own block and also adds its text to the enclosing cell.
|
|
1173
|
+
- **Page headers, footers, footnotes and endnotes**, one block list per part, unless `extras: false`.
|
|
1174
|
+
|
|
1175
|
+
**The HTML**, in this order:
|
|
1176
|
+
|
|
1177
|
+
```html
|
|
1178
|
+
<!doctype html><html><head><title>…</title></head><body>
|
|
1179
|
+
<header data-part="header">…</header> one per header part, BEFORE the body
|
|
1180
|
+
<section data-heading="…" data-level="1"><h1 id="…">…</h1>…</section> the body, sectioned (§7)
|
|
1181
|
+
<footer data-part="footer">…</footer> one per footer part
|
|
1182
|
+
<aside data-part="notes"><ol><li id="footnote-1" data-kind="footnote">…</li></ol></aside>
|
|
1183
|
+
</body></html>
|
|
1184
|
+
```
|
|
1185
|
+
|
|
1186
|
+
Headings become `<h1>`…`<h6>` (Word's levels 7–9 all become `<h6>`), paragraphs `<p data-style="…">`, list
|
|
1187
|
+
runs nested `<ul>`/`<ol>`, links `<a href>`. Text is escaped, and each `\n` becomes `<br>`. A table becomes
|
|
1188
|
+
`<table data-name="table N">`, all `<td>` (no `<th>`), the top-left cell of each merged range carrying
|
|
1189
|
+
`rowspan`/`colspan` and the covered cells left out. The body goes through the same `sectioned` pass as
|
|
1190
|
+
Markdown (§7), which re-serialises it through cheerio: that is why the body's tables have a `<tbody>` and the
|
|
1191
|
+
headers' do not.
|
|
1192
|
+
|
|
1193
|
+
The FCC's regulatory fee fact sheet for the Media Bureau
|
|
1194
|
+
([`examples/fcc-regulatory-fees/`](../../examples/fcc-regulatory-fees)) has four header parts, the second
|
|
1195
|
+
holding an address table, before a body of paragraphs and tables. Its radio-station fee table, as the engine
|
|
1196
|
+
builds it:
|
|
1197
|
+
|
|
1198
|
+
<!-- capture:doc-docx-fcc screenshot alt=The_FCC_fee_table_as_HTML:_a_title_merged_across_seven_columns,_two_paragraphs_in_each_header_and_value_cell -->
|
|
1199
|
+

|
|
1200
|
+
<!-- /capture -->
|
|
1201
|
+
|
|
1202
|
+
```html
|
|
1203
|
+
<tr><td colspan="7">FY 2026 RADIO STATION REGULATORY FEES, PAYMENT TYPE CODE & FEE</td></tr>
|
|
1204
|
+
<tr><td>Population <br>Served</td><td>AM Class A</td>…<td>FM Classes<br>A, B1 & C3</td>…</tr>
|
|
1205
|
+
<tr><td><=10,000</td><td>2659<br>$560</td><td>2660<br>$405</td>…</tr>
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
Each header and value cell is two Word paragraphs. The HTML table reader (§6) reads `<br>` as a space, so
|
|
1209
|
+
`FM Classes<br>A, B1 & C3` is the key `FM Classes A, B1 & C3` and `2659<br>$560` the value `2659 $560`.
|
|
1210
|
+
The page header holds a table and headers come first, so this is `table 2`, both as the HTML table reader's
|
|
1211
|
+
sheet name and as the Word reader's `data-name` (one numbering over the whole document: headers, body, footers,
|
|
1212
|
+
notes):
|
|
1213
|
+
|
|
1214
|
+
<!-- capture:doc-docx-fcc scope ids=table -->
|
|
1215
|
+
```json
|
|
1216
|
+
{
|
|
1217
|
+
"table": {
|
|
1218
|
+
"sheet": "table 2",
|
|
1219
|
+
"title": "Population Served",
|
|
1220
|
+
"header": [
|
|
1221
|
+
"Population Served",
|
|
1222
|
+
"AM Class A",
|
|
1223
|
+
"AM Class B",
|
|
1224
|
+
"… 4 more"
|
|
1225
|
+
],
|
|
1226
|
+
"rows": [
|
|
1227
|
+
{
|
|
1228
|
+
"population": "<=10,000",
|
|
1229
|
+
"amClassA": "2659 $560",
|
|
1230
|
+
"amClassB": "2660 $405",
|
|
1231
|
+
"amClassC": "2661 $350",
|
|
1232
|
+
"amClassD": "2662 $385",
|
|
1233
|
+
"fmClassesA": "2663 $615",
|
|
1234
|
+
"fmClassesB": "2664 $700"
|
|
1235
|
+
},
|
|
1236
|
+
{
|
|
1237
|
+
"population": "10,001 – 25,000",
|
|
1238
|
+
"amClassA": "2617 $935",
|
|
1239
|
+
"amClassB": "2623 $675",
|
|
1240
|
+
"amClassC": "2629 $585",
|
|
1241
|
+
"amClassD": "2635 $645",
|
|
1242
|
+
"fmClassesA": "2641 $1,025",
|
|
1243
|
+
"fmClassesB": "2647 $1,170"
|
|
1244
|
+
},
|
|
1245
|
+
{
|
|
1246
|
+
"population": "25,001 – 75,000",
|
|
1247
|
+
"amClassA": "2618 $1,405",
|
|
1248
|
+
"amClassB": "2624 $1,015",
|
|
1249
|
+
"amClassC": "2630 $880",
|
|
1250
|
+
"amClassD": "2636 $970",
|
|
1251
|
+
"fmClassesA": "2642 $1,540",
|
|
1252
|
+
"fmClassesB": "2648 $1,755"
|
|
1253
|
+
},
|
|
1254
|
+
"… 6 more"
|
|
1255
|
+
]
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
```
|
|
1259
|
+
<!-- /capture -->
|
|
1260
|
+
|
|
1261
|
+
The mapping splits each value into the payment type code and the fee with a `regex` transform each:
|
|
1262
|
+
|
|
1263
|
+
<!-- capture:doc-docx-fcc mapping fields=populationServed,fmClassesA.paymentTypeCode,fmClassesA.feeUsd -->
|
|
1264
|
+
| Field | Step | Value |
|
|
1265
|
+
|---|---|---|
|
|
1266
|
+
| `populationServed` | read | `"<=10,000"` |
|
|
1267
|
+
| | `trim` | `"<=10,000"` |
|
|
1268
|
+
| | **field** | `"<=10,000"` |
|
|
1269
|
+
| `fmClassesA.paymentTypeCode` | read | `"2663 $615"` |
|
|
1270
|
+
| | `regex` | `"2663"` |
|
|
1271
|
+
| | **field** | `"2663"` |
|
|
1272
|
+
| `fmClassesA.feeUsd` | read | `"2663 $615"` |
|
|
1273
|
+
| | `regex` | `"615"` |
|
|
1274
|
+
| | `number` | `615` |
|
|
1275
|
+
| | **field** | `615` |
|
|
1276
|
+
<!-- /capture -->
|
|
1277
|
+
|
|
1278
|
+
## 6. HTML tables
|
|
1279
|
+
|
|
1280
|
+
`htmlTableSheets` ([`html-tables.mapper.ts:17-47`](../../packages/core/src/workbook-document/html-tables.mapper.ts#L17-L47))
|
|
1281
|
+
turns every `<table>` of an HTML document into a sheet; it serves fetched HTML, rendered Markdown, Word
|
|
1282
|
+
documents and, in web mode, the live page:
|
|
1283
|
+
|
|
1284
|
+
1. cheerio parses the document; **every** `<table>`, in document order, becomes `table 1`, `table 2`…
|
|
1285
|
+
2. A table's rows are the `<tr>`s whose closest `<table>` is this one, so a nested table's rows belong to it,
|
|
1286
|
+
not to its parent. `<thead>`, `<tbody>` and `<tfoot>` rows are read in document order.
|
|
1287
|
+
3. Its cells are the `<th>` and `<td>` children of each row, alike.
|
|
1288
|
+
4. **Placement follows the HTML table model**: skip the columns an earlier `rowspan` occupies, mark the
|
|
1289
|
+
`rowspan × colspan` block, write the text into its top-left slot, and record a span over 1 as an A1 range in
|
|
1290
|
+
`merges`. A span is `trunc(Number(attr))` when finite and positive, capped at 1000, else 1: `colspan="0"`
|
|
1291
|
+
counts as 1. The grid is cut to the number of `<tr>`s, so a `rowspan` past the last row is dropped.
|
|
1292
|
+
5. **Cell text** (`cellText`, `:59-66`): text nodes give their text, `<br>` gives a **space**, and a block
|
|
1293
|
+
element (`p`, `div`, `li`, `h1`–`h6`, `table`, `td`… 29 of them) is wrapped in spaces; inline elements
|
|
1294
|
+
(`b`, `span`, `a`) add nothing. Whitespace then collapses to one space and the text is trimmed. So
|
|
1295
|
+
`<p>FM Classes</p><p>A, B1 & C3</p>` reads `FM Classes A, B1 & C3`, and `<b>Fee</b> <i>due</i>: $1,<span>200</span>`
|
|
1296
|
+
reads `Fee due: $1,200`. A nested table's text also counts in its parent's cell.
|
|
1297
|
+
6. The sheets go through the grid algorithm of §3.4, merges filled, with `headerRows`, `columns`, `until` and
|
|
1298
|
+
`fillDown`.
|
|
1299
|
+
|
|
1300
|
+
This rule is the table reader's own. A `css` extract with `take: "text"` and `xpath` text on HTML read
|
|
1301
|
+
`a<br>b` as `ab`.
|
|
1302
|
+
|
|
1303
|
+
## 7. Markdown
|
|
1304
|
+
|
|
1305
|
+
`readMarkdown` ([`read-markdown.client.ts:43-89`](../../packages/core/src/markdown-document/read-markdown.client.ts#L43-L89)):
|
|
1306
|
+
|
|
1307
|
+
1. **Front matter**: a `---` block at the very start (after the BOM is stripped) is parsed as YAML 1.2 (§8);
|
|
1308
|
+
YAML that does not parse fails the step.
|
|
1309
|
+
2. **The body** is rendered by `marked` as GitHub-flavoured Markdown: tables, task lists, strikethrough,
|
|
1310
|
+
autolinks. Raw HTML is kept; it is data, parsed by cheerio and never run.
|
|
1311
|
+
3. **Sections**: `sectioned` walks the top-level nodes; each `<h1>`–`<h6>` closes any open section of the same
|
|
1312
|
+
or a deeper level and opens `<section data-heading="<text>" data-level="<n>">`. The heading gets GitHub's
|
|
1313
|
+
slug `id` (lower case, punctuation dropped, spaces to hyphens, `-1`, `-2` for repeats, skipping ids already
|
|
1314
|
+
taken, so `a`, `a`, `a-1` give `a`, `a-1`, `a-1-1`). Headings nested
|
|
1315
|
+
inside other elements are not sectioned.
|
|
1316
|
+
4. **Output**: `<!doctype html><html><head><script type="application/json" data-front-matter>…</script></head><body>…`,
|
|
1317
|
+
`<` in the JSON escaped as `\u003c` so a value holding `</script>` cannot close the element.
|
|
1318
|
+
|
|
1319
|
+
The document is `html`, so `css`, `table` (§6), `xpath` and `regex` read it. The repository's
|
|
1320
|
+
[`listino.md`](../../packages/core/src/markdown-document/fixtures/listino.md) has front matter with
|
|
1321
|
+
`market: NO`, a table with an escaped pipe and a short row, a task list, and raw HTML. The front matter is read
|
|
1322
|
+
the way JSON-LD is, `css` then `jsonpath` with `from`; the task list through its section:
|
|
1323
|
+
|
|
1324
|
+
<!-- capture:doc-markdown-listino scope ids=front,updated,accessories -->
|
|
1325
|
+
```json
|
|
1326
|
+
{
|
|
1327
|
+
"front": "{\"title\":\"Listino giugno\",\"updated\":\"2026-06-01\",\"brand\":\"Fiat\",\"market\":\"NO\"}",
|
|
1328
|
+
"updated": "2026-06-01",
|
|
1329
|
+
"accessories": [
|
|
1330
|
+
"Ruotino",
|
|
1331
|
+
"Gancio traino"
|
|
1332
|
+
]
|
|
1333
|
+
}
|
|
1334
|
+
```
|
|
1335
|
+
<!-- /capture -->
|
|
1336
|
+
|
|
1337
|
+
`market: NO` stays the string `"NO"` (YAML 1.2). In the table, `con \| pipe` keeps its pipe and the row with two
|
|
1338
|
+
cells is padded:
|
|
1339
|
+
|
|
1340
|
+
<!-- capture:doc-markdown-listino records n=3 -->
|
|
1341
|
+
```json
|
|
1342
|
+
{"model":"Pandina","version":"1.0 Hybrid","price":"15.950 €","note":"con | pipe","updated":"2026-06-01"}
|
|
1343
|
+
{"model":"600e","version":"La Prima","price":"36.950 €","note":"bold link","updated":"2026-06-01"}
|
|
1344
|
+
{"model":"Solo due celle","version":"x","price":null,"note":null,"updated":"2026-06-01"}
|
|
1345
|
+
```
|
|
1346
|
+
<!-- /capture -->
|
|
1347
|
+
|
|
1348
|
+
## 8. YAML
|
|
1349
|
+
|
|
1350
|
+
`readYaml` ([`read-yaml.client.ts:29-47`](../../packages/core/src/yaml-document/read-yaml.client.ts#L29-L47))
|
|
1351
|
+
calls the `yaml` package's `parseAllDocuments` with `version: '1.2'`, whatever the file declares, so a
|
|
1352
|
+
`%YAML 1.1` directive does not turn `NO` into `false` or `0123` into octal. The schema is `core` for
|
|
1353
|
+
`scalars: "typed"` (the default) and `failsafe` for `"text"`, which keeps every scalar as written (`0123`,
|
|
1354
|
+
`1.10`). Merge keys (`<<: *base`) are applied; duplicate keys are an error; an alias count above 100 fails,
|
|
1355
|
+
which defeats a "billion laughs" file; a custom tag (`!!js/function`) is read as its plain value, with a
|
|
1356
|
+
warning event. Several documents (`---`) give an array. The result is a `json` document: `jsonpath` reads YAML
|
|
1357
|
+
as it reads JSON.
|
|
1358
|
+
|
|
1359
|
+
## 9. XML
|
|
1360
|
+
|
|
1361
|
+
Reading ([`http.client.ts:187-197`](../../packages/core/src/http-session/http.client.ts#L187-L197)): bytes that
|
|
1362
|
+
start with `1F 8B` are gunzipped (a `sitemap.xml.gz` served as a file), the text is decoded (§3.1), parsed once
|
|
1363
|
+
to validate it (a body that looks like HTML gets the hint `read it with "as": "html"`), and kept **as text**,
|
|
1364
|
+
`{ kind: 'xml', xml }`. Parsing
|
|
1365
|
+
([`xml-parser.client.ts`](../../packages/core/src/xml-document/xml-parser.client.ts)) uses `@xmldom/xmldom`,
|
|
1366
|
+
failing on fatal errors only; entities a DOCTYPE declares are never expanded and external ones never fetched;
|
|
1367
|
+
the last 16 parsed documents are cached by their text.
|
|
1368
|
+
|
|
1369
|
+
`xpath` ([`xpath.algorithm.ts:28-43`](../../packages/core/src/xml-document/xpath.algorithm.ts#L28-L43)) is XPath
|
|
1370
|
+
1.0. Prefixes declared on the **root element** are known; the step's `namespaces` add to them. A **default**
|
|
1371
|
+
namespace has no prefix, so an Atom feed's `<entry>` matches `//a:entry` with `"namespaces": { "a":
|
|
1372
|
+
"http://www.w3.org/2005/Atom" }`, or `//entry` with `"ignoreNamespaces": true`, which queries a copy with local
|
|
1373
|
+
names only (comments and processing instructions dropped). An unknown prefix fails with that hint. A function
|
|
1374
|
+
(`count()`, `string()`) gives one value. `take` is `text` (collapsed), `value` (as is), `html` (inner markup),
|
|
1375
|
+
`json` (outer markup, namespace declarations included, to query again with `from`) or `attr:<name>`. On HTML,
|
|
1376
|
+
`xpath` parses the page as a browser would (`<tbody>` inserted) and queries it without namespaces. `css` on XML
|
|
1377
|
+
uses cheerio's XML mode; `jsonpath` on XML is refused.
|
|
1378
|
+
|
|
1379
|
+
## 10. JSON and JSON Lines
|
|
1380
|
+
|
|
1381
|
+
`parseJsonLike` ([`json-text.algorithm.ts:29-42`](../../packages/core/src/selection/json-text.algorithm.ts#L29-L42))
|
|
1382
|
+
tries `JSON.parse` first. Only when that fails does it strip one wrapper and parse strictly what is inside:
|
|
1383
|
+
comment or CDATA guards around JSON-LD, an anti-hijacking prefix (`)]}'`, `while(1);`, `for(;;);`), a JSONP call
|
|
1384
|
+
`cb({…});`, or an assignment `window.__STATE__ = {…};`. Nothing is evaluated. A JSON body that still fails names
|
|
1385
|
+
the URL, and adds `it looks like JSON Lines: read it with "as": "jsonl"` when the text has two or more non-blank
|
|
1386
|
+
lines and the first parses alone. JSON Lines (`parseJsonLines`, `:52-62`) read one value per non-blank line into
|
|
1387
|
+
an array and name the line that fails. `jsonpath` is `jsonpath-plus` with `wrap: true`: a path always gives a
|
|
1388
|
+
list.
|
|
1389
|
+
|
|
1390
|
+
## 11. Constants at a glance
|
|
1391
|
+
|
|
1392
|
+
| Where | Constant | Value |
|
|
1393
|
+
|---|---|---|
|
|
1394
|
+
| PDF cells | `JOIN_GAP` | 0.35 × font size |
|
|
1395
|
+
| PDF cells | `SPACE_GAP` | 0.1 × font size |
|
|
1396
|
+
| PDF lines | `SAME_BASELINE` | 0.2 × max(font sizes, 1) |
|
|
1397
|
+
| PDF rows | `ROW_OVERLAP` | 0.4 × cell height |
|
|
1398
|
+
| PDF columns | band tolerance | max(3 pt, 0.6 × median cell height) |
|
|
1399
|
+
| PDF columns | band slack | 0.5 pt |
|
|
1400
|
+
| PDF rows | `TIE` | 1 pt, a tie goes to the anchor below |
|
|
1401
|
+
| CSV | delimiters | `,` `;` tab `\|`, in order of preference |
|
|
1402
|
+
| CSV | sample | 64 KiB, 100 rows |
|
|
1403
|
+
| HTML tables | span cap | 1000 |
|
|
1404
|
+
| Office packages | per part / total | 256 MiB / 512 MiB declared |
|
|
1405
|
+
| XLSX dates | Unix epoch serial, 1904 offset | 25 569, 1462 days |
|
|
1406
|
+
| PPTX | EMU per point | 12 700 |
|
|
1407
|
+
| Word styles | `basedOn` depth | 20 |
|
|
1408
|
+
| YAML | aliases | 100 |
|
|
1409
|
+
| XML | parse cache | 16 documents |
|
|
1410
|
+
|
|
1411
|
+
---
|
|
1412
|
+
|
|
1413
|
+
Next: [8. Mapping and transforms](07-mapping-and-transforms.md), from scope to record.
|