@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.
Files changed (60) hide show
  1. package/README.md +27 -1
  2. package/dist/diff-tool/diff-tool.handler.d.ts +42 -0
  3. package/dist/diff-tool/index.d.ts +3 -0
  4. package/dist/docs-tool/docs-folder.repository.d.ts +24 -0
  5. package/dist/docs-tool/docs-root.client.d.ts +8 -0
  6. package/dist/docs-tool/docs-tool.handler.d.ts +21 -0
  7. package/dist/docs-tool/docs-topics.algorithm.d.ts +39 -0
  8. package/dist/docs-tool/index.d.ts +3 -0
  9. package/dist/index.d.ts +12 -1
  10. package/dist/index.esm.js +430 -1
  11. package/dist/list-tool/index.d.ts +3 -0
  12. package/dist/list-tool/list-recipes-tool.handler.d.ts +32 -0
  13. package/dist/main.d.ts +7 -0
  14. package/dist/probe-tool/index.d.ts +2 -0
  15. package/dist/probe-tool/probe-tool.handler.d.ts +27 -0
  16. package/dist/recipe-source/index.d.ts +3 -0
  17. package/dist/recipe-source/recipe-source.mapper.d.ts +21 -0
  18. package/dist/run-tool/index.d.ts +3 -0
  19. package/dist/run-tool/run-tool.handler.d.ts +51 -0
  20. package/dist/server/create-server.use-case.d.ts +11 -0
  21. package/dist/server/index.d.ts +2 -0
  22. package/dist/src/docs-tool/docs-folder.repository.d.ts +24 -0
  23. package/dist/src/docs-tool/docs-root.client.d.ts +8 -0
  24. package/dist/src/docs-tool/docs-tool.handler.d.ts +21 -0
  25. package/dist/src/docs-tool/docs-topics.algorithm.d.ts +39 -0
  26. package/dist/src/docs-tool/index.d.ts +3 -0
  27. package/dist/src/server/create-server.use-case.d.ts +1 -1
  28. package/dist/src/studio-tool/index.d.ts +2 -0
  29. package/dist/src/studio-tool/studio-command.client.d.ts +11 -0
  30. package/dist/src/studio-tool/studio-instance.repository.d.ts +22 -0
  31. package/dist/src/studio-tool/studio-tools.handler.d.ts +47 -0
  32. package/dist/studio-tool/index.d.ts +2 -0
  33. package/dist/studio-tool/studio-command.client.d.ts +11 -0
  34. package/dist/studio-tool/studio-instance.repository.d.ts +22 -0
  35. package/dist/studio-tool/studio-tools.handler.d.ts +47 -0
  36. package/dist/validate-tool/index.d.ts +3 -0
  37. package/dist/validate-tool/validate-tool.handler.d.ts +31 -0
  38. package/docs/how-it-works/01-recipes.md +698 -0
  39. package/docs/how-it-works/02-page-steps.md +672 -0
  40. package/docs/how-it-works/03-data-steps.md +813 -0
  41. package/docs/how-it-works/04-flow-steps.md +738 -0
  42. package/docs/how-it-works/05-templates-and-scope.md +428 -0
  43. package/docs/how-it-works/06-documents.md +1413 -0
  44. package/docs/how-it-works/07-mapping-and-transforms.md +699 -0
  45. package/docs/how-it-works/08-policies.md +405 -0
  46. package/docs/how-it-works/09-running.md +804 -0
  47. package/docs/how-it-works/README.md +225 -0
  48. package/docs/recipes/README.md +22 -0
  49. package/docs/recipes/access.md +440 -0
  50. package/docs/recipes/authoring.md +1503 -0
  51. package/docs/recipes/azure-durable.md +180 -0
  52. package/docs/recipes/captcha.md +204 -0
  53. package/docs/recipes/movies-example.md +12 -0
  54. package/docs/recipes/programmatic-authoring.md +101 -0
  55. package/docs/recipes/quick-guide.md +157 -0
  56. package/docs/recipes/vehicles-example.md +9 -0
  57. package/docs/recipes/worker-mode.md +221 -0
  58. package/docs/studio/manual.md +312 -0
  59. package/docs/studio/pdf-agreements.md +150 -0
  60. 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
+ ![Page 1 of the ENPAM sheet, every cell readPdf found outlined, header rows filled](../assets/how-it-works/pdf-discounts.png)
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
+ ![The end of the Fiat table: a note row, then the Abarth header](../assets/how-it-works/doc-pdf-enpam-2.png)
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
+ ![The Fiat table: headers centred, cells left-aligned](../assets/how-it-works/doc-pdf-enpam.png)
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
+ ![The (PROMO VALIDA…) header, over the discount column and the empty notes column](../assets/how-it-works/doc-pdf-enpam-5.png)
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
+ ![The fixture's ALPHA, BETA and DELTA tables, each row in one colour](../assets/how-it-works/doc-pdf-fixture.png)
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
+ ![The Alfa Romeo table: notes wrapped around names and discounts centred beside them](../assets/how-it-works/doc-pdf-enpam-3.png)
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
+ ![The Jeep table: 420 AVENGER BEV's discount sits on the line below its name](../assets/how-it-works/doc-pdf-enpam-4.png)
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
+ ![The DS table: each extra's first line sits halfway between two rows](../assets/how-it-works/doc-pdf-enpam-6.png)
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
+ ![The FCC fee table as HTML: a title merged across seven columns, two paragraphs in each header and value cell](../assets/how-it-works/doc-docx-fcc.png)
1200
+ <!-- /capture -->
1201
+
1202
+ ```html
1203
+ <tr><td colspan="7">FY 2026 RADIO STATION REGULATORY FEES, PAYMENT TYPE CODE &amp; FEE</td></tr>
1204
+ <tr><td>Population <br>Served</td><td>AM Class A</td>…<td>FM Classes<br>A, B1 &amp; C3</td>…</tr>
1205
+ <tr><td>&lt;=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 &amp; 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 &amp; 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.