brewdoc 0.2.0__py3-none-any.whl
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.
- brewdoc/__init__.py +16 -0
- brewdoc/cli.py +97 -0
- brewdoc/common.py +405 -0
- brewdoc/docx.py +131 -0
- brewdoc/htmltext.py +812 -0
- brewdoc/output.py +93 -0
- brewdoc/pdf.py +1185 -0
- brewdoc/pptx.py +362 -0
- brewdoc/selfcheck.py +383 -0
- brewdoc/service.py +116 -0
- brewdoc/sheets.py +341 -0
- brewdoc-0.2.0.dist-info/METADATA +232 -0
- brewdoc-0.2.0.dist-info/RECORD +16 -0
- brewdoc-0.2.0.dist-info/WHEEL +4 -0
- brewdoc-0.2.0.dist-info/entry_points.txt +2 -0
- brewdoc-0.2.0.dist-info/licenses/LICENSE +21 -0
brewdoc/selfcheck.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Shipped self-check for synthetic documents and presentations, with no fixture files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import io
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import brewdoc.htmltext as htmltext
|
|
13
|
+
from brewdoc.common import (UNIT_COUNTERS, _escape_markdown_text, cell_text, markdown_table,
|
|
14
|
+
new_tally, sanitise, sha256)
|
|
15
|
+
from brewdoc.docx import render_doc
|
|
16
|
+
from brewdoc.pdf import render_pdf
|
|
17
|
+
from brewdoc.pptx import render_presentation
|
|
18
|
+
from brewdoc.service import EXIT_FAIL, EXIT_OK, run
|
|
19
|
+
from brewdoc.sheets import render_book
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def synthetic_pdf(pages, font: bool = True) -> bytes:
|
|
23
|
+
"""A minimal valid PDF printing each page's content stream verbatim; `font=False` for a scan."""
|
|
24
|
+
objects, count = {}, len(pages)
|
|
25
|
+
objects[1] = b"<< /Type /Catalog /Pages 2 0 R >>"
|
|
26
|
+
objects[2] = ("<< /Type /Pages /Kids [%s] /Count %d >>"
|
|
27
|
+
% (" ".join("%d 0 R" % (3 + i) for i in range(count)), count)).encode("ascii")
|
|
28
|
+
first_stream = 3 + count
|
|
29
|
+
resources = " /Font << /F1 %d 0 R >>" % (first_stream + count) if font else ""
|
|
30
|
+
for index, body in enumerate(pages):
|
|
31
|
+
objects[3 + index] = (
|
|
32
|
+
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources <<%s >> "
|
|
33
|
+
"/Contents %d 0 R >>" % (resources, first_stream + index)).encode("ascii")
|
|
34
|
+
data = body.encode("latin-1")
|
|
35
|
+
objects[first_stream + index] = (("<< /Length %d >>\nstream\n" % len(data)).encode("ascii")
|
|
36
|
+
+ data + b"\nendstream")
|
|
37
|
+
if font:
|
|
38
|
+
objects[first_stream + count] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
|
|
39
|
+
buffer, offsets = io.BytesIO(), {}
|
|
40
|
+
buffer.write(b"%PDF-1.4\n")
|
|
41
|
+
for number in sorted(objects):
|
|
42
|
+
offsets[number] = buffer.tell()
|
|
43
|
+
buffer.write(("%d 0 obj\n" % number).encode("ascii"))
|
|
44
|
+
buffer.write(objects[number])
|
|
45
|
+
buffer.write(b"\nendobj\n")
|
|
46
|
+
start, size = buffer.tell(), max(objects) + 1
|
|
47
|
+
buffer.write(("xref\n0 %d\n" % size).encode("ascii") + b"0000000000 65535 f \n")
|
|
48
|
+
for number in range(1, size):
|
|
49
|
+
buffer.write(("%010d 00000 n \n" % offsets.get(number, 0)).encode("ascii"))
|
|
50
|
+
buffer.write(("trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n"
|
|
51
|
+
% (size, start)).encode("ascii"))
|
|
52
|
+
return buffer.getvalue()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _text_op(x: float, y: float, text: str, size: int = 10) -> str:
|
|
56
|
+
return "BT /F1 %d Tf 1 0 0 1 %s %s Tm (%s) Tj ET\n" % (size, x, y, text)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _rule(x: float, y: float, width: float, height: float = 0.5) -> str:
|
|
60
|
+
return "%s %s %s %s re f\n" % (x, y, width, height)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
FIXTURE_TABLE = [_text_op(80, 700, "Table 1. Yield by zone."),
|
|
64
|
+
_text_op(80, 685, "Zone") + _text_op(200, 685, "Area") + _text_op(320, 685, "Yield"),
|
|
65
|
+
_text_op(80, 670, "North") + _text_op(200, 670, "12.5") + _text_op(320, 670, "4.10"),
|
|
66
|
+
_text_op(80, 655, "South") + _text_op(200, 655, "9.0") + _text_op(320, 655, "3.75")]
|
|
67
|
+
FIXTURE_COLUMNS = [_text_op(60, 600 - 12 * i, "left column line %d of the body text here" % i)
|
|
68
|
+
+ _text_op(330, 600 - 12 * i, "right column line %d of the body text" % i)
|
|
69
|
+
for i in range(12)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fixture_pages() -> list[str]:
|
|
73
|
+
body = "".join(FIXTURE_TABLE) + "".join(FIXTURE_COLUMNS)
|
|
74
|
+
return [_text_op(72, 760, "Seasonal Operations Manual") + body + _text_op(300, 40, "%d" % n)
|
|
75
|
+
for n in (1, 2, 3)]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# p1: a grid closed on the header row alone, plus a subscripted cell. p2: rules only across.
|
|
79
|
+
FIXTURE_UNCLOSED = (
|
|
80
|
+
"".join(_rule(100, y, 300) for y in (700, 685, 670, 655, 640))
|
|
81
|
+
+ "".join(_rule(x, 640, 0.5, 60) for x in (100, 200, 300))
|
|
82
|
+
+ _rule(400, 685, 0.5, 15)
|
|
83
|
+
+ "".join(_text_op(x, 689, text) for x, text in ((105, "Zone"), (205, "Area"), (305, "Yield")))
|
|
84
|
+
+ "".join(_text_op(x, 674, text) for x, text in ((105, "North"), (205, "12.5"), (305, "4.10")))
|
|
85
|
+
+ "".join(_text_op(x, 659, text) for x, text in ((105, "South"), (205, "9.0"), (305, "3.75")))
|
|
86
|
+
+ _text_op(105, 644, "Salt") + _text_op(305, 644, "1.0")
|
|
87
|
+
+ _text_op(205, 644, "P") + _text_op(211, 641, "2", 6)
|
|
88
|
+
+ _text_op(215, 644, "O") + _text_op(221, 641, "5", 6))
|
|
89
|
+
FIXTURE_SEGMENTED = (
|
|
90
|
+
"".join(_rule(x, 600, 99.5) for x in (100, 200, 300, 400))
|
|
91
|
+
+ "".join(_text_op(x, 585, text)
|
|
92
|
+
for x, text in ((105, "Crop"), (205, "Zone"), (305, "Area"), (405, "Yield")))
|
|
93
|
+
+ "".join(_text_op(x, 570, text)
|
|
94
|
+
for x, text in ((105, "Maize"), (205, "North"), (305, "12.5"), (405, "4.10")))
|
|
95
|
+
+ "".join(_text_op(x, 555, text)
|
|
96
|
+
for x, text in ((105, "Wheat"), (205, "South"), (305, "9.0"), (405, "3.75")))
|
|
97
|
+
# Prose, so the region's word-space yardstick is a word space and not a column gap.
|
|
98
|
+
+ "".join(_text_op(100, 520 - 12 * i, "a prose line with ordinary word spaces")
|
|
99
|
+
for i in range(6)))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
SHEET_ROWS = [["Zone", "Area", "Sown"], ["North", 12.5, "2026-07-18"], ["South", 9.0, ""]]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _synthetic_xlsx(path: Path) -> None:
|
|
106
|
+
def cell(column: str, row: int, value):
|
|
107
|
+
reference = "%s%d" % (column, row)
|
|
108
|
+
if isinstance(value, float):
|
|
109
|
+
return '<c r="%s"><v>%r</v></c>' % (reference, value)
|
|
110
|
+
return ('<c r="%s" t="inlineStr"><is><t>%s</t></is></c>' % (reference, value)
|
|
111
|
+
if value != "" else '<c r="%s"/>' % reference)
|
|
112
|
+
rows = "".join(
|
|
113
|
+
'<row r="%d">%s</row>' % (index, "".join(
|
|
114
|
+
cell(chr(ord("A") + position), index, value) for position, value in enumerate(row)))
|
|
115
|
+
for index, row in enumerate(SHEET_ROWS, 1))
|
|
116
|
+
with zipfile.ZipFile(path, "w") as book:
|
|
117
|
+
book.writestr("[Content_Types].xml",
|
|
118
|
+
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/'
|
|
119
|
+
'package/2006/content-types"><Default Extension="rels" ContentType='
|
|
120
|
+
'"application/vnd.openxmlformats-package.relationships+xml"/><Override '
|
|
121
|
+
'PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-'
|
|
122
|
+
'officedocument.spreadsheetml.sheet.main+xml"/><Override PartName='
|
|
123
|
+
'"/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-'
|
|
124
|
+
'officedocument.spreadsheetml.worksheet+xml"/></Types>')
|
|
125
|
+
book.writestr("_rels/.rels",
|
|
126
|
+
'<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats'
|
|
127
|
+
'.org/package/2006/relationships"><Relationship Id="rId1" Type="http://'
|
|
128
|
+
'schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"'
|
|
129
|
+
' Target="xl/workbook.xml"/></Relationships>')
|
|
130
|
+
book.writestr("xl/_rels/workbook.xml.rels",
|
|
131
|
+
'<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats'
|
|
132
|
+
'.org/package/2006/relationships"><Relationship Id="rId1" Type="http://'
|
|
133
|
+
'schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
|
|
134
|
+
'Target="worksheets/sheet1.xml"/></Relationships>')
|
|
135
|
+
book.writestr("xl/workbook.xml",
|
|
136
|
+
'<?xml version="1.0"?><workbook xmlns="http://schemas.openxmlformats.org/'
|
|
137
|
+
'spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/'
|
|
138
|
+
'officeDocument/2006/relationships"><sheets><sheet name="Zones" sheetId="1" '
|
|
139
|
+
'r:id="rId1"/></sheets></workbook>')
|
|
140
|
+
book.writestr("xl/worksheets/sheet1.xml",
|
|
141
|
+
'<?xml version="1.0"?><worksheet xmlns="http://schemas.openxmlformats.org/'
|
|
142
|
+
'spreadsheetml/2006/main"><sheetData>%s</sheetData></worksheet>' % rows)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
DOC_TABLE = [[("Dose table", 3)], [("Product", 1), ("Dose", 1), ("BBCH", 1)],
|
|
146
|
+
[("Moddus", 1), ("0.4", 1), ("31", 1)]]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def synthetic_docx() -> bytes:
|
|
150
|
+
"""A minimal .docx: one Heading1, one paragraph with a `w:br`, one table with a spanning row."""
|
|
151
|
+
def cell(text: str, span: int) -> str:
|
|
152
|
+
grid = '<w:tcPr><w:gridSpan w:val="%d"/></w:tcPr>' % span if span > 1 else ""
|
|
153
|
+
return "<w:tc>%s<w:p><w:r><w:t>%s</w:t></w:r></w:p></w:tc>" % (grid, text)
|
|
154
|
+
rows = "".join("<w:tr>%s</w:tr>" % "".join(cell(text, span) for text, span in row)
|
|
155
|
+
for row in DOC_TABLE)
|
|
156
|
+
body = ('<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Growth regulators</w:t>'
|
|
157
|
+
'</w:r></w:p>'
|
|
158
|
+
'<w:p><w:r><w:t>first line</w:t><w:br/><w:t>second line</w:t></w:r>'
|
|
159
|
+
'<w:del><w:r><w:delText>struck out</w:delText></w:r></w:del></w:p>'
|
|
160
|
+
'<w:tbl>%s</w:tbl>' % rows)
|
|
161
|
+
stream = io.BytesIO()
|
|
162
|
+
with zipfile.ZipFile(stream, "w") as package:
|
|
163
|
+
package.writestr("[Content_Types].xml",
|
|
164
|
+
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/'
|
|
165
|
+
'package/2006/content-types"><Default Extension="rels" ContentType='
|
|
166
|
+
'"application/vnd.openxmlformats-package.relationships+xml"/><Override '
|
|
167
|
+
'PartName="/word/document.xml" ContentType="application/vnd.'
|
|
168
|
+
'openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
|
|
169
|
+
'</Types>')
|
|
170
|
+
package.writestr("_rels/.rels",
|
|
171
|
+
'<?xml version="1.0"?><Relationships xmlns="http://schemas.'
|
|
172
|
+
'openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" '
|
|
173
|
+
'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
|
|
174
|
+
'relationships/officeDocument" Target="word/document.xml"/>'
|
|
175
|
+
'</Relationships>')
|
|
176
|
+
package.writestr("word/document.xml",
|
|
177
|
+
'<?xml version="1.0"?><w:document xmlns:w="http://schemas.'
|
|
178
|
+
'openxmlformats.org/wordprocessingml/2006/main"><w:body>%s</w:body>'
|
|
179
|
+
'</w:document>' % body)
|
|
180
|
+
return stream.getvalue()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def synthetic_pptx() -> bytes:
|
|
184
|
+
"""Return a one-slide presentation with a title and body text."""
|
|
185
|
+
stream = io.BytesIO()
|
|
186
|
+
with zipfile.ZipFile(stream, "w") as package:
|
|
187
|
+
package.writestr(
|
|
188
|
+
"_rels/.rels",
|
|
189
|
+
'<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/'
|
|
190
|
+
'package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.'
|
|
191
|
+
'openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
|
|
192
|
+
'Target="ppt/presentation.xml"/></Relationships>',
|
|
193
|
+
)
|
|
194
|
+
package.writestr(
|
|
195
|
+
"ppt/presentation.xml",
|
|
196
|
+
'<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
|
|
197
|
+
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
|
198
|
+
'<p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>',
|
|
199
|
+
)
|
|
200
|
+
package.writestr(
|
|
201
|
+
"ppt/_rels/presentation.xml.rels",
|
|
202
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
203
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/'
|
|
204
|
+
'2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>',
|
|
205
|
+
)
|
|
206
|
+
package.writestr(
|
|
207
|
+
"ppt/slides/slide1.xml",
|
|
208
|
+
'<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
|
|
209
|
+
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree>'
|
|
210
|
+
'<p:nvGrpSpPr/><p:grpSpPr/><p:sp><p:nvSpPr><p:cNvPr id="2" name="Title 1"/>'
|
|
211
|
+
'<p:cNvSpPr/><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr><p:spPr/>'
|
|
212
|
+
'<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Harvest plan</a:t></a:r>'
|
|
213
|
+
'</a:p></p:txBody></p:sp><p:sp><p:nvSpPr><p:cNvPr id="3" name="Content 2"/>'
|
|
214
|
+
'<p:cNvSpPr/><p:nvPr><p:ph type="body"/></p:nvPr></p:nvSpPr><p:spPr/>'
|
|
215
|
+
'<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>North field</a:t></a:r>'
|
|
216
|
+
'</a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>',
|
|
217
|
+
)
|
|
218
|
+
return stream.getvalue()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def self_check() -> int:
|
|
222
|
+
"""Render synthetic fixtures and compare against pinned expectations; 0 when green."""
|
|
223
|
+
failures, checks = [], []
|
|
224
|
+
|
|
225
|
+
def want(label, got, expected):
|
|
226
|
+
checks.append(label)
|
|
227
|
+
if got != expected:
|
|
228
|
+
failures.append("%s: got %r want %r" % (label, got, expected))
|
|
229
|
+
|
|
230
|
+
tally = new_tally()
|
|
231
|
+
want("sanitise folds and counts",
|
|
232
|
+
sanitise(" a b (cid:9) office dišerent \x01x – ≥", tally),
|
|
233
|
+
"- a b office diserent x - >=")
|
|
234
|
+
want("every class counted", (tally["dropped"]["pua_glyphs"], tally["dropped"]["nbsp"],
|
|
235
|
+
tally["dropped"]["cid_survivors"], tally["dropped"]["ligatures"],
|
|
236
|
+
tally["dropped"]["soft_hyphens"],
|
|
237
|
+
tally["dropped"]["control_chars"],
|
|
238
|
+
tally["broken_ligature_words"]),
|
|
239
|
+
(1, 1, 1, 1, 1, 1, 1))
|
|
240
|
+
want("a float keeps its spreadsheet form", cell_text(85.0), "85.0")
|
|
241
|
+
want("an empty cell is '' and renders empty", cell_text(""), "")
|
|
242
|
+
want("a date is one ISO day", cell_text(datetime.date(2026, 7, 18)), "2026-07-18")
|
|
243
|
+
want("a ragged table is padded, never cut", markdown_table([["a", "b"], ["c"]]),
|
|
244
|
+
["| a | b |", "| --- | --- |", "| c | |"])
|
|
245
|
+
|
|
246
|
+
with tempfile.TemporaryDirectory(prefix="brewdoc_docx_") as tmp:
|
|
247
|
+
doc_path = Path(tmp, "fixture.docx")
|
|
248
|
+
doc_path.write_bytes(synthetic_docx())
|
|
249
|
+
markdown, tally = render_doc(doc_path)
|
|
250
|
+
want("a Word heading opens its own chapter",
|
|
251
|
+
markdown.count('## Chapter 1: "Growth regulators"'), 1)
|
|
252
|
+
want("a w:br is a line break, and a deleted run is not carried",
|
|
253
|
+
chapter_lines(markdown, "Growth regulators")[:2], ["first line", "second line"])
|
|
254
|
+
want("a spanning cell is padded, so every column stays under its own head",
|
|
255
|
+
chapter_lines(markdown, "Growth regulators")[2:],
|
|
256
|
+
["| Dose table | | |", "| --- | --- | --- |", "| Product | Dose | BBCH |",
|
|
257
|
+
"| Moddus | 0.4 | 31 |"])
|
|
258
|
+
want("the docx tally counts its chapter and its table",
|
|
259
|
+
(tally["chapters"], tally["tables"], tally["text_regions"]), (1, 1, 1))
|
|
260
|
+
|
|
261
|
+
with tempfile.TemporaryDirectory(prefix="brewdoc_pptx_") as tmp:
|
|
262
|
+
presentation_path = Path(tmp, "fixture.pptx")
|
|
263
|
+
presentation_path.write_bytes(synthetic_pptx())
|
|
264
|
+
markdown, tally = render_presentation(presentation_path)
|
|
265
|
+
want("a presentation title labels its slide once",
|
|
266
|
+
(markdown.count('## Slide 1: "Harvest plan"'),
|
|
267
|
+
chapter_lines(markdown, "Harvest plan")),
|
|
268
|
+
(1, ["North field"]))
|
|
269
|
+
want("the presentation tally counts its declared slide", tally["slides"], 1)
|
|
270
|
+
want("a presentation render is deterministic",
|
|
271
|
+
sha256(render_presentation(presentation_path)[0]), sha256(markdown))
|
|
272
|
+
|
|
273
|
+
with tempfile.TemporaryDirectory(prefix="brewdoc_") as tmp:
|
|
274
|
+
pdf_path = Path(tmp, "fixture.pdf")
|
|
275
|
+
pdf_path.write_bytes(synthetic_pdf(_fixture_pages()))
|
|
276
|
+
markdown, tally = render_pdf(pdf_path)
|
|
277
|
+
lines = markdown.splitlines()
|
|
278
|
+
want("the captioned unlined table recovers its cells",
|
|
279
|
+
lines[lines.index("| Zone | Area | Yield |") + 2], "| North | 12.5 | 4.10 |")
|
|
280
|
+
want("the blank rows the text strategy invents are gone",
|
|
281
|
+
markdown.count("| | | |"), 0)
|
|
282
|
+
want("the caption stays prose", markdown.count("Table 1. Yield by zone."), 3)
|
|
283
|
+
want("two-column prose is read column by column",
|
|
284
|
+
lines[lines.index("left column line 0 of the body text here") + 1],
|
|
285
|
+
"left column line 1 of the body text here")
|
|
286
|
+
want("the region was split at the gutter", tally["columns_split"], 3)
|
|
287
|
+
want("the running head is dropped once per page",
|
|
288
|
+
(markdown.count("Seasonal Operations Manual"), tally["dropped"]["running_heads"]),
|
|
289
|
+
(0, 3))
|
|
290
|
+
want("the page number is dropped", tally["dropped"]["page_numbers"], 3)
|
|
291
|
+
want("determinism sha256", sha256(render_pdf(pdf_path)[0]), sha256(markdown))
|
|
292
|
+
|
|
293
|
+
ruled_path = Path(tmp, "ruled.pdf")
|
|
294
|
+
ruled_path.write_bytes(synthetic_pdf([FIXTURE_UNCLOSED, FIXTURE_SEGMENTED]))
|
|
295
|
+
ruled, _ = render_pdf(ruled_path)
|
|
296
|
+
want("a column closed on the header row alone is still read",
|
|
297
|
+
chapter_lines(ruled, "Page 1")[:5],
|
|
298
|
+
["| Zone | Area | Yield |", "| --- | --- | --- |", "| North | 12.5 | 4.10 |",
|
|
299
|
+
"| South | 9.0 | 3.75 |", "| Salt | P2O5 | 1.0 |"])
|
|
300
|
+
want("a rule cut into one segment per column carries the grid",
|
|
301
|
+
chapter_lines(ruled, "Page 2")[:4],
|
|
302
|
+
["| Crop | Zone | Area | Yield |", "| --- | --- | --- | --- |",
|
|
303
|
+
"| Maize | North | 12.5 | 4.10 |", "| Wheat | South | 9.0 | 3.75 |"])
|
|
304
|
+
want("a repaired ruling stays deterministic",
|
|
305
|
+
sha256(render_pdf(ruled_path)[0]), sha256(ruled))
|
|
306
|
+
|
|
307
|
+
scan = Path(tmp, "scan.pdf")
|
|
308
|
+
scan.write_bytes(synthetic_pdf(["0 0 0 rg 100 100 200 200 re f\n"] * 2, font=False))
|
|
309
|
+
rc, line, _ = run(scan)
|
|
310
|
+
want("a document with no text layer is refused by name",
|
|
311
|
+
(rc, line["file_ok"], line["reason"].split(" in ")[0]),
|
|
312
|
+
(EXIT_FAIL, False, "no text layer: 2 of 2 pages carry zero characters"))
|
|
313
|
+
|
|
314
|
+
book_path = Path(tmp, "fixture.xlsx")
|
|
315
|
+
_synthetic_xlsx(book_path)
|
|
316
|
+
book_md, book_tally = render_book(book_path)
|
|
317
|
+
want("one chapter per sheet",
|
|
318
|
+
[row for row in book_md.splitlines() if row.startswith("## Sheet ")],
|
|
319
|
+
['## Sheet 1: "Zones"'])
|
|
320
|
+
want("the sheet's measured types survive",
|
|
321
|
+
chapter_lines(book_md, "Zones"),
|
|
322
|
+
["| Zone | Area | Sown |", "| --- | --- | --- |",
|
|
323
|
+
"| North | 12.5 | 2026-07-18 |", "| South | 9.0 | |"])
|
|
324
|
+
want("the sheet route counts its sheets", book_tally["sheets"], 1)
|
|
325
|
+
out = Path(tmp, "out.md")
|
|
326
|
+
rc, line, _ = run(book_path, out=out)
|
|
327
|
+
want("a written render is ASCII and re-readable",
|
|
328
|
+
(rc, line["route"], out.read_text(encoding="ascii") == book_md), (EXIT_OK, "sheet", True))
|
|
329
|
+
rc, line, _ = run(Path(tmp, "absent.pdf"))
|
|
330
|
+
want("a missing file names the path", (rc, line["reason"]),
|
|
331
|
+
(EXIT_FAIL, "no such file: %s" % Path(tmp, "absent.pdf")))
|
|
332
|
+
|
|
333
|
+
html_path = Path(tmp, "fixture.html")
|
|
334
|
+
html_path.write_bytes('<title>Static HTML</title><p>café 東京</p><table><tr><th>Zone</th>'
|
|
335
|
+
'<th>Area</th></tr><tr><td>North</td><td>12.5</td></tr></table>'.encode("utf-8"))
|
|
336
|
+
rc, line, html_md = run(html_path)
|
|
337
|
+
if htmltext._SUPPORTED_RUNTIME:
|
|
338
|
+
want("HTML's native parser is available on supported CPython",
|
|
339
|
+
(rc, line["file_ok"], line["route"], line["unit_kind"], line["units"]),
|
|
340
|
+
(EXIT_OK, True, "html", "chapter", 1))
|
|
341
|
+
if rc == EXIT_OK:
|
|
342
|
+
want("HTML preserves Unicode prose and complete table cells",
|
|
343
|
+
chapter_lines(html_md, "Static HTML"),
|
|
344
|
+
["café 東京", "| Zone | Area |", "| --- | --- |", r"| North | 12\.5 |"])
|
|
345
|
+
want("HTML counts its chapter, table and prose",
|
|
346
|
+
(line["units"], line["tables"], line["text_regions"], line["unit_keys"]),
|
|
347
|
+
(1, 1, 1, ["chapter/000001"]))
|
|
348
|
+
html_out = Path(tmp, "html.md")
|
|
349
|
+
second_rc, second_line, second_md = run(html_path, out=html_out)
|
|
350
|
+
want("HTML writes deterministic UTF-8 without a BOM",
|
|
351
|
+
(second_rc, second_line["file_ok"], second_md,
|
|
352
|
+
html_out.read_bytes() if second_rc == EXIT_OK else None),
|
|
353
|
+
(EXIT_OK, True, html_md, html_md.encode("utf-8")))
|
|
354
|
+
else:
|
|
355
|
+
want("an unsupported HTML runtime explicitly refuses its static route",
|
|
356
|
+
(rc, line["file_ok"], line["route"], line["unit_kind"], line["units"],
|
|
357
|
+
line["reason"], html_md),
|
|
358
|
+
(EXIT_FAIL, False, "html", "chapter", 0, "HTML requires CPython 3.12-3.14", ""))
|
|
359
|
+
|
|
360
|
+
for failure in failures:
|
|
361
|
+
print("FAIL %s" % failure)
|
|
362
|
+
print("self-check: %s (%d checks)" % ("FAIL" if failures else "ok", len(checks)))
|
|
363
|
+
return EXIT_FAIL if failures else EXIT_OK
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def chapter_lines(markdown: str, heading: str) -> list[str]:
|
|
367
|
+
"""Return content under a plain heading or any registered unit display label."""
|
|
368
|
+
lines = markdown.splitlines()
|
|
369
|
+
label = ': "%s"' % _escape_markdown_text(heading)
|
|
370
|
+
unit_headings = tuple("## %s " % kind.title() for kind in UNIT_COUNTERS)
|
|
371
|
+
unit_anchor = r'<a id="brewdoc-(?:%s)-\d{6}"></a>' % "|".join(
|
|
372
|
+
re.escape(kind) for kind in UNIT_COUNTERS)
|
|
373
|
+
matched = next((index for index, line in enumerate(lines) if line == "## " + heading
|
|
374
|
+
or (line.startswith(unit_headings) and line.endswith(label))),
|
|
375
|
+
None)
|
|
376
|
+
if matched is None:
|
|
377
|
+
raise ValueError("chapter heading not found: %s" % heading)
|
|
378
|
+
start = matched + 1
|
|
379
|
+
rest = [index for index, line in enumerate(lines[start:], start)
|
|
380
|
+
if line.startswith("## ")
|
|
381
|
+
or re.match(unit_anchor, line)]
|
|
382
|
+
body = lines[start:rest[0]] if rest else lines[start:]
|
|
383
|
+
return [line for line in body if line.strip()]
|
brewdoc/service.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Service: suffix routes, `run` and its receipt line, output writes through `output`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from brewdoc.common import MARKDOWN_SCHEMA, RECEIPT_SCHEMA, BrewdocError, Route, new_tally
|
|
10
|
+
from brewdoc.docx import ROUTE as DOC_ROUTE
|
|
11
|
+
from brewdoc.htmltext import ROUTE as HTML_ROUTE
|
|
12
|
+
from brewdoc.output import _output_targets, _write_outputs
|
|
13
|
+
from brewdoc.pdf import ROUTE as PDF_ROUTE
|
|
14
|
+
from brewdoc.pptx import ROUTE as PRESENTATION_ROUTE
|
|
15
|
+
from brewdoc.sheets import ROUTE as SHEET_ROUTE
|
|
16
|
+
from brewdoc.sheets import _artifact_payloads, _sheet_selection
|
|
17
|
+
|
|
18
|
+
EXIT_OK, EXIT_FAIL, EXIT_USAGE = 0, 1, 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _route_map(*routes: Route) -> dict[str, Route]:
|
|
22
|
+
"""Suffix -> route; two adapters claiming one suffix is a defect, not a last-one-wins race."""
|
|
23
|
+
table: dict[str, Route] = {}
|
|
24
|
+
for route in routes:
|
|
25
|
+
for suffix in route.suffixes:
|
|
26
|
+
if suffix in table:
|
|
27
|
+
raise BrewdocError("suffix '%s' is claimed by both the %s and %s routes"
|
|
28
|
+
% (suffix, table[suffix].name, route.name))
|
|
29
|
+
table[suffix] = route
|
|
30
|
+
return table
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
ROUTES = _route_map(PDF_ROUTE, DOC_ROUTE, PRESENTATION_ROUTE, SHEET_ROUTE, HTML_ROUTE)
|
|
34
|
+
SUFFIXES = " ".join(sorted(ROUTES))
|
|
35
|
+
NO_ROUTE = Route("none", "none", (), None, ())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _route(path: Path) -> Route:
|
|
39
|
+
return ROUTES.get(path.suffix.lower(), NO_ROUTE)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _line(route: Route, file_ok: bool, reason: str, path, tally: dict | None = None,
|
|
43
|
+
out=None, unit_keys: tuple[str, ...] = (), artifacts: tuple[dict, ...] = (),
|
|
44
|
+
selected_sheets=None) -> dict:
|
|
45
|
+
tally = tally or new_tally()
|
|
46
|
+
line = {"file_ok": file_ok, "route": route.name, "reason": reason,
|
|
47
|
+
"source": Path(path).name, "out": str(out) if out else None,
|
|
48
|
+
"receipt_schema": RECEIPT_SCHEMA, "unit_kind": route.unit_kind,
|
|
49
|
+
"units": len(unit_keys), "tables": tally["tables"],
|
|
50
|
+
"text_regions": tally["text_regions"], "columns_split": tally["columns_split"],
|
|
51
|
+
"dropped": dict(tally["dropped"]),
|
|
52
|
+
"broken_ligature_words": tally["broken_ligature_words"],
|
|
53
|
+
"not_carried": list(route.not_carried)}
|
|
54
|
+
if file_ok:
|
|
55
|
+
line.update({"artifacts": list(artifacts), "markdown_schema": MARKDOWN_SCHEMA,
|
|
56
|
+
"unit_keys": list(unit_keys)})
|
|
57
|
+
if selected_sheets is not None:
|
|
58
|
+
line["selected_sheets"] = list(selected_sheets)
|
|
59
|
+
return line
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _artifact_output_pairs(artifact_outputs) -> tuple[tuple[str, Path, str], ...]:
|
|
63
|
+
"""Validate a key -> path mapping; each pair keeps the caller's path text for the receipt."""
|
|
64
|
+
if artifact_outputs is None:
|
|
65
|
+
return ()
|
|
66
|
+
if not isinstance(artifact_outputs, Mapping):
|
|
67
|
+
raise BrewdocError("artifact outputs must map keys to paths")
|
|
68
|
+
pairs = []
|
|
69
|
+
for key, raw_path in artifact_outputs.items():
|
|
70
|
+
if not (isinstance(key, str) and key and isinstance(raw_path, (str, os.PathLike))
|
|
71
|
+
and str(raw_path)):
|
|
72
|
+
raise BrewdocError("malformed artifact assignment: %r" % ((key, raw_path),))
|
|
73
|
+
pairs.append((key, Path(raw_path), str(raw_path)))
|
|
74
|
+
return tuple(pairs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run(path, out=None, *, sheets=None, artifact_outputs=None) -> tuple[int, dict, str]:
|
|
78
|
+
"""(exit code, receipt, Markdown); every path returns a receipt, a refusal names what and where."""
|
|
79
|
+
path = Path(path)
|
|
80
|
+
route = _route(path)
|
|
81
|
+
if route.render is None:
|
|
82
|
+
return EXIT_FAIL, _line(route, False, "unsupported suffix '%s' in %s: brewdoc reads %s"
|
|
83
|
+
% (path.suffix.lower(), path, SUFFIXES), path), ""
|
|
84
|
+
if not path.is_file():
|
|
85
|
+
return EXIT_FAIL, _line(route, False, "no such file: %s" % path, path), ""
|
|
86
|
+
try:
|
|
87
|
+
sheets = _sheet_selection(sheets)
|
|
88
|
+
pairs = _artifact_output_pairs(artifact_outputs)
|
|
89
|
+
if route.name != "sheet" and (sheets is not None or pairs):
|
|
90
|
+
raise BrewdocError("--sheet and --artifact are workbook-only options")
|
|
91
|
+
targets = _output_targets(path, out, pairs)
|
|
92
|
+
markdown, tally, unit_keys, references = route.render(path, sheets)
|
|
93
|
+
keys = tuple(key for key, _target, _shown in pairs)
|
|
94
|
+
listed = {item.key for item in references}
|
|
95
|
+
unknown = [key for key in keys if key not in listed]
|
|
96
|
+
if unknown:
|
|
97
|
+
raise BrewdocError("unknown artifact key: %s" % ", ".join(unknown))
|
|
98
|
+
payloads = ((markdown.encode("utf-8" if route.name == "html" else "ascii"),)
|
|
99
|
+
if out is not None else ()) + _artifact_payloads(
|
|
100
|
+
path, sheets, references, keys)
|
|
101
|
+
except BrewdocError as exc:
|
|
102
|
+
return EXIT_FAIL, _line(route, False, str(exc), path), ""
|
|
103
|
+
except Exception as exc: # a third-party parser raises its own types
|
|
104
|
+
return EXIT_FAIL, _line(route, False, "%s unreadable: %s: %s: %s"
|
|
105
|
+
% (route.name, path, type(exc).__name__, exc), path), ""
|
|
106
|
+
try:
|
|
107
|
+
_write_outputs(tuple(zip(targets, payloads)))
|
|
108
|
+
except (BrewdocError, OSError) as exc:
|
|
109
|
+
return EXIT_FAIL, _line(route, False, "output write failed: %s" % exc, path), ""
|
|
110
|
+
reason = "%s rendered: %d %ss, %d tables, %d text regions, %d column splits" % (
|
|
111
|
+
route.name, len(unit_keys), route.unit_kind, tally["tables"], tally["text_regions"],
|
|
112
|
+
tally["columns_split"])
|
|
113
|
+
shown = {key: text for key, _target, text in pairs}
|
|
114
|
+
receipt_artifacts = tuple(item.to_dict(shown.get(item.key)) for item in references)
|
|
115
|
+
return EXIT_OK, _line(route, True, reason, path, tally, out, unit_keys,
|
|
116
|
+
receipt_artifacts, sheets), markdown
|