Three of round 9's four measured holes, each closed with a rule chosen on a measurement rather than named as a limit. `rtf` GIVES 0 SEGMENTS -> 6 of 6 AUTHORED TITLES over N = 4. The container has no heading style, so the author's title is bold text. The grammar is markdown, not `rtf`: the converter already writes that title as `**...**` in the same output every office row produces, so no `rtf`-only heading form exists. Three parameters were swept over 47 readable documents and ONE carried -- refusing a line that ends in terminal punctuation takes false-positive lines from 9-12 to 1-2. A maximum title length (unlimited/40/60/80/120) and a must-stand-between-blank-lines clause are both FLAT, so neither is in the rule. The last false positive is closed by G1, the principle `_gate_outline` already carries: recovery yields to declaration. False positives are then 0 of the 31 declaring documents by construction, and 0 of 27 on the corpus. Reach: 2 of 39 corpus documents, both `docx`, 0 of 33 `pdf` and 0 of 2 `xlsx`. Behind `--bold-title`, default OFF pending the hit@8 measurement; the default bundle is byte-identical without it. BOTH ALTERNATIVES THE ORDER NAMED WERE MEASURED AND FELLED. A fourth hand-laid fixture DECLARES heading styles in a stylesheet and the converter discards them, emitting the same bold line -- so "read the declared headings out of the markdown" has nothing to read. `rtf` -> `docx` -> markdown yields 0 ATX headings on that same document, because the loss is in the `rtf` READER before any writer sees the style. Fixtures are hand-laid in `make_k2_office.py` with the fasit written first; they live in their own directory because Door B walks a drop directory recursively and `k2-office/` reads its N off the listing. THE PREFIX OVER-MATCH: THREE CANDIDATES MEASURED, ALL THREE FAILED ON ONE ROW. Re-measured on the pinned 453-concept bundle with the control run first: `under` occurs 79 times by equality and matches 172 by prefix, `undersjoisk` 0 and 172, `bilateral` 0 and 400 of 453, `standhaftig` 0 and 219. The two extra known-negatives were FOUND, not chosen -- every 4-character prefix ranked by document frequency, then a real word taken from the widest. A longer floor (5-8), a coverage share (0.5-0.8) and a long-words-only floor (>= 8) each cost row 1 its rank on the default bundle and the whole row on Arm B. Decomposed: row 1's token `prisene` reaches its gold document through `pris|sammenstilling` on four characters -- 0.57 of one word and 0.22 of the other -- so the over-match and the wanted match are one mechanism. THE FOURTH CANDIDATE IS THE ANSWER: the shared prefix must be a WORD the bundle uses. `pris` is; `bila` and `stan` are not. `bilateral` 400 -> 0 and 512 -> 0, `standhaftig` 219 -> 56 and 235 -> 33, every hit@8 row keeping rank 1 on BOTH bundles. `undersjoisk` stops at 162 because `under` IS a word here -- a genuine Norwegian morpheme, so that residual is a different answer, not a ceiling. ON by default (`--no-stem-prefix`), pinned with its own known-negative on the shipped bytes. THE SHIM: a path importer holds the object `module_from_spec` made, and `sys.modules[__name__] = _impl` never reaches it. Measured under both counting methods -- 3 of 76 public names by `vars()`. One line copies the public names into this file's globals; the dunder filter is load-bearing, because an unfiltered copy overwrites `__name__` before the next line uses it as the alias key. It restores attribute ACCESS and not patch-through, which is why the alias stays. A CHANGELOG note under 0.7.0 and a shim docstring line say so, since what the consumer asked for was the note. Suite 1515 -> 1535. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
431 lines
19 KiB
Python
431 lines
19 KiB
Python
"""Regenerate the K2 office fixture set: one pptx, one odt, one rtf.
|
|
|
|
Three containers carrying the SAME document, so the only variable between the
|
|
three measurements is the container and the reader that opens it. Hand-laid,
|
|
part by part, with no generator library anywhere -- for the reason
|
|
`make_fixtures.py` states and this set inherits: a file written by the
|
|
converter and then read by the converter proves only that the converter agrees
|
|
with itself, and would stay green through any conversion defect that is
|
|
symmetric, which is most of them. The order that commissioned this set offered
|
|
pandoc as one generator option; this repository's committed fixture policy
|
|
forbids it, and the policy wins.
|
|
|
|
The document is invented, and that is a constraint rather than a detail: a
|
|
plausible Norwegian requirements sheet carrying the diacritics the pipeline has
|
|
to survive, and nobody's real document. No private file, nothing from
|
|
`~/Documents`, nothing from a customer.
|
|
|
|
Run from the repository root: python3 tests/fixtures/make_k2_office.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).parent
|
|
OUT = HERE / "k2-office"
|
|
#: Round 10's rtf variants live in their OWN directory, and that is not tidiness.
|
|
#: `test_k2_office_fixtures.py` reads N off `k2-office/` by listing it, because
|
|
#: Door B walks a drop directory RECURSIVELY -- anything parked beside the three
|
|
#: containers would enter that run and the denominator would stop being three.
|
|
RTF_OUT = HERE / "k2-rtf-variants"
|
|
|
|
TITLE = "Kravspesifikasjon for tunnelbelysning"
|
|
INTRO = "Dokumentet samler kravene til belysning i vegtunneler over 500 meter."
|
|
|
|
# The pairing table. Every label ends in a colon, which is the rule
|
|
# `okf_fidelity.label_value_rows` selects on -- so these twenty rows ARE the
|
|
# pairable denominator, and a converter that drops a value or breaks a row over
|
|
# two output lines shows up as a fall from 20.
|
|
PAIRS: tuple[tuple[str, str], ...] = (
|
|
("Dokumentnummer:", "SVV-2026-0417"),
|
|
("Tittel:", "Tunnelbelysning i hovedløpet"),
|
|
("Ansvarlig avdeling:", "Utbyggingsdivisjonen"),
|
|
("Fagområde:", "Elektro og belysning"),
|
|
("Versjon:", "2.3 godkjent"),
|
|
("Gyldig fra:", "01.03.2026"),
|
|
("Erstatter:", "SVV-2024-0188"),
|
|
("Tunnellengde:", "1240 meter"),
|
|
("Dimensjonerende fart:", "80 km/t"),
|
|
("Årsdøgntrafikk:", "12400 kjøretøy"),
|
|
("Terskelluminans:", "145 candela"),
|
|
("Overgangssone:", "Tre trinn nedtrapping"),
|
|
("Innerstrekning:", "3,0 candela"),
|
|
("Utkjøringssone:", "Ingen forsterkning"),
|
|
("Fargetemperatur:", "4000 kelvin"),
|
|
("Fargegjengivelse:", "Ra større enn 70"),
|
|
("Nødbelysning:", "60 minutter drift"),
|
|
("Vedlikeholdsfaktor:", "0,80 beregnet"),
|
|
("Målemetode:", "Måling med luminanskamera"),
|
|
("Avvikshåndtering:", "Søknad om fravik"),
|
|
)
|
|
|
|
# The 4x4 the order asks for, and a second question. No cell here ends in a
|
|
# colon, so the grid contributes to COVERAGE and to nothing else: a converter
|
|
# that flattens a grid into prose keeps its coverage and loses its shape, and
|
|
# the two numbers are meant to be able to move independently.
|
|
# The caption between the two tables. A PARAGRAPH, not an empty one: an
|
|
# empty `\\pard\\par` left the converter joining the 2-column rows and the
|
|
# 4-column rows into a single four-column table, so the grid stopped being a
|
|
# second table at all.
|
|
GRID_CAPTION = "Luminansmatrise per trafikklasse"
|
|
|
|
GRID: tuple[tuple[str, ...], ...] = (
|
|
("Sone", "Klasse A", "Klasse B", "Klasse C"),
|
|
("Terskel", "150 cd", "120 cd", "95 cd"),
|
|
("Overgang", "45 cd", "36 cd", "28 cd"),
|
|
("Indre", "3,5 cd", "3,0 cd", "2,5 cd"),
|
|
)
|
|
|
|
|
|
def _escape(text: str) -> str:
|
|
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
_ZIP_DATE = (2020, 1, 1, 0, 0, 0)
|
|
_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
|
|
|
|
def build_container(parts: dict[str, str], *, stored_first: str | None = None) -> bytes:
|
|
"""Zip the parts with a fixed timestamp, so the fixture is byte-reproducible.
|
|
|
|
A zip records mtime, so without a constant `date_time` the fixture would
|
|
differ on every regeneration and `git diff --quiet` could not be the check.
|
|
`stored_first` exists for ODF, whose specification requires `mimetype` to be
|
|
the first member and stored uncompressed.
|
|
"""
|
|
out = io.BytesIO()
|
|
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
names = list(parts)
|
|
if stored_first is not None:
|
|
names.remove(stored_first)
|
|
info = zipfile.ZipInfo(stored_first, date_time=_ZIP_DATE)
|
|
info.compress_type = zipfile.ZIP_STORED
|
|
archive.writestr(info, parts[stored_first])
|
|
for name in names:
|
|
info = zipfile.ZipInfo(name, date_time=_ZIP_DATE)
|
|
info.compress_type = zipfile.ZIP_DEFLATED
|
|
archive.writestr(info, parts[name])
|
|
return out.getvalue()
|
|
|
|
|
|
# --- odt ---------------------------------------------------------------------
|
|
|
|
_ODT_NS = (
|
|
' xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"'
|
|
' xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"'
|
|
' xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"'
|
|
' xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"'
|
|
' xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"'
|
|
' office:version="1.3"'
|
|
)
|
|
|
|
|
|
def _odt_table(name: str, rows: tuple[tuple[str, ...], ...]) -> str:
|
|
columns = len(rows[0])
|
|
body = "".join(
|
|
"<table:table-row>"
|
|
+ "".join(
|
|
f'<table:table-cell office:value-type="string">'
|
|
f"<text:p>{_escape(cell)}</text:p></table:table-cell>"
|
|
for cell in row
|
|
)
|
|
+ "</table:table-row>"
|
|
for row in rows
|
|
)
|
|
return (
|
|
f'<table:table table:name="{name}">'
|
|
f'<table:table-column table:number-columns-repeated="{columns}"/>'
|
|
f"{body}</table:table>"
|
|
)
|
|
|
|
|
|
def odt_parts() -> dict[str, str]:
|
|
content = (
|
|
f"{_XML}<office:document-content{_ODT_NS}><office:body><office:text>"
|
|
f'<text:h text:outline-level="1">{_escape(TITLE)}</text:h>'
|
|
f"<text:p>{_escape(INTRO)}</text:p>"
|
|
+ _odt_table("Krav", tuple(PAIRS))
|
|
+ f"<text:p>{_escape(GRID_CAPTION)}</text:p>"
|
|
+ _odt_table("Luminansmatrise", GRID)
|
|
+ "</office:text></office:body></office:document-content>"
|
|
)
|
|
return {
|
|
"mimetype": "application/vnd.oasis.opendocument.text",
|
|
"META-INF/manifest.xml": _XML
|
|
+ '<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"'
|
|
+ ' manifest:version="1.3">'
|
|
+ '<manifest:file-entry manifest:full-path="/"'
|
|
+ ' manifest:media-type="application/vnd.oasis.opendocument.text"/>'
|
|
+ '<manifest:file-entry manifest:full-path="content.xml"'
|
|
+ ' manifest:media-type="text/xml"/>'
|
|
+ '<manifest:file-entry manifest:full-path="styles.xml"'
|
|
+ ' manifest:media-type="text/xml"/>'
|
|
+ "</manifest:manifest>",
|
|
"styles.xml": _XML
|
|
+ f"<office:document-styles{_ODT_NS}><office:styles/></office:document-styles>",
|
|
"content.xml": content,
|
|
}
|
|
|
|
|
|
# --- pptx --------------------------------------------------------------------
|
|
|
|
_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
|
_P = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
|
_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
|
|
|
|
def _pptx_text_body(text: str) -> str:
|
|
return "<p:txBody><a:bodyPr/><a:p><a:r><a:t>" + _escape(text) + "</a:t></a:r></a:p></p:txBody>"
|
|
|
|
|
|
def _pptx_shape(shape_id: int, name: str, text: str) -> str:
|
|
return (
|
|
"<p:sp><p:nvSpPr>"
|
|
f'<p:cNvPr id="{shape_id}" name="{_escape(name)}"/><p:cNvSpPr/><p:nvPr/>'
|
|
"</p:nvSpPr><p:spPr/>" + _pptx_text_body(text) + "</p:sp>"
|
|
)
|
|
|
|
|
|
def _pptx_table(shape_id: int, name: str, rows: tuple[tuple[str, ...], ...]) -> str:
|
|
columns = len(rows[0])
|
|
grid = "".join('<a:gridCol w="2000000"/>' for _ in range(columns))
|
|
body = "".join(
|
|
'<a:tr h="370840">'
|
|
+ "".join(
|
|
"<a:tc><a:txBody><a:bodyPr/><a:p><a:r><a:t>"
|
|
+ _escape(cell)
|
|
+ "</a:t></a:r></a:p></a:txBody><a:tcPr/></a:tc>"
|
|
for cell in row
|
|
)
|
|
+ "</a:tr>"
|
|
for row in rows
|
|
)
|
|
return (
|
|
"<p:graphicFrame><p:nvGraphicFramePr>"
|
|
f'<p:cNvPr id="{shape_id}" name="{_escape(name)}"/>'
|
|
"<p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr>"
|
|
'<p:xfrm><a:off x="0" y="0"/><a:ext cx="8000000" cy="4000000"/></p:xfrm>'
|
|
f'<a:graphic><a:graphicData uri="{_A}/table">'
|
|
f"<a:tbl><a:tblPr/><a:tblGrid>{grid}</a:tblGrid>{body}</a:tbl>"
|
|
"</a:graphicData></a:graphic></p:graphicFrame>"
|
|
)
|
|
|
|
|
|
def _pptx_slide(shapes: str) -> str:
|
|
return (
|
|
f'{_XML}<p:sld xmlns:a="{_A}" xmlns:r="{_R}" xmlns:p="{_P}">'
|
|
"<p:cSld><p:spTree>"
|
|
'<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
|
|
"<p:grpSpPr/>" + shapes + "</p:spTree></p:cSld></p:sld>"
|
|
)
|
|
|
|
|
|
def pptx_parts() -> dict[str, str]:
|
|
slide_one = _pptx_slide(
|
|
_pptx_shape(2, "Tittel", TITLE)
|
|
+ _pptx_shape(3, "Ingress", INTRO)
|
|
+ _pptx_table(4, "Kravtabell", tuple(PAIRS))
|
|
)
|
|
slide_two = _pptx_slide(
|
|
_pptx_shape(2, "Undertittel", GRID_CAPTION) + _pptx_table(3, "Luminansmatrise", GRID)
|
|
)
|
|
rels = (
|
|
f'{_XML}<Relationships xmlns="{_R}">'
|
|
'<Relationship Id="rId1" Type="{t}/slide" Target="slides/slide1.xml"/>'
|
|
'<Relationship Id="rId2" Type="{t}/slide" Target="slides/slide2.xml"/>'
|
|
"</Relationships>"
|
|
).format(t=_R)
|
|
return {
|
|
"[Content_Types].xml": _XML
|
|
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|
+ '<Default Extension="xml" ContentType="application/xml"/>'
|
|
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
|
+ '<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
|
|
+ '<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>'
|
|
+ '<Override PartName="/ppt/slides/slide2.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>'
|
|
+ "</Types>",
|
|
"_rels/.rels": _XML
|
|
+ f'<Relationships xmlns="{_R}">'
|
|
+ f'<Relationship Id="rId1" Type="{_R}/officeDocument" Target="ppt/presentation.xml"/>'
|
|
+ "</Relationships>",
|
|
"ppt/_rels/presentation.xml.rels": rels,
|
|
"ppt/presentation.xml": _XML
|
|
+ f'<p:presentation xmlns:a="{_A}" xmlns:r="{_R}" xmlns:p="{_P}">'
|
|
+ '<p:sldIdLst><p:sldId id="256" r:id="rId1"/><p:sldId id="257" r:id="rId2"/></p:sldIdLst>'
|
|
+ "</p:presentation>",
|
|
"ppt/slides/slide1.xml": slide_one,
|
|
"ppt/slides/slide2.xml": slide_two,
|
|
"ppt/slides/_rels/slide1.xml.rels": _XML + f'<Relationships xmlns="{_R}"/>',
|
|
"ppt/slides/_rels/slide2.xml.rels": _XML + f'<Relationships xmlns="{_R}"/>',
|
|
}
|
|
|
|
|
|
# --- rtf ---------------------------------------------------------------------
|
|
#
|
|
# RTF carries no unicode of its own: a character above the code page is written
|
|
# as an escape with an ASCII replacement beside it, for a reader too old to
|
|
# understand the escape. Emitting the letters raw would make the fixture's bytes
|
|
# depend on a code page nobody declared.
|
|
#
|
|
# THE SPACE IN `\\uN ?` IS LOAD-BEARING AND IT IS NOT COSMETIC. The form Word
|
|
# emits is `\\uN?` with no delimiter, and the vendored converter reads that as
|
|
# the control word delimited BY the `?`, then applies `\\uc1` to the character
|
|
# AFTER it. Measured directly: `A\\u248?BC` comes back as `AoC` with the ring
|
|
# letter in place and the `B` GONE; `A\\u248?xBC` comes back as `AoBC`, which
|
|
# is the same rule seen from the other side. An explicit space delimits the
|
|
# control word, so the `?` is what gets skipped and the text survives. The
|
|
# fixture is therefore written in the form that round-trips, and the form that
|
|
# does not is recorded in `docs/2026-09-07-k2-pptx-odt-rtf-fixtures.md` as a
|
|
# converter finding rather than worked around in silence.
|
|
|
|
_RTF_CELL_WIDTH = 3000
|
|
|
|
|
|
def _rtf_escape(text: str) -> str:
|
|
out = []
|
|
for char in text:
|
|
if char in "\\{}":
|
|
out.append("\\" + char)
|
|
elif ord(char) < 128:
|
|
out.append(char)
|
|
else:
|
|
out.append(f"\\u{ord(char)} ?")
|
|
return "".join(out)
|
|
|
|
|
|
def _rtf_row(row: tuple[str, ...]) -> str:
|
|
"""One table row. `\\pard\\intbl` PER CELL, and it is load-bearing.
|
|
|
|
Measured against the vendored converter while building this fixture: the
|
|
same rows WITHOUT `\\intbl` are read as each row nested inside the previous
|
|
one -- five label/value rows came back as five levels of nested table and
|
|
2076 characters where 117 were expected, exit code 0 and no warning. That
|
|
is the same shape as the missing `word/styles.xml` and the `inlineStr`
|
|
xlsx recorded in `make_fixtures.py`: structurally plausible input, silently
|
|
wrong output, nothing anywhere saying so.
|
|
"""
|
|
borders = "".join(f"\\cellx{_RTF_CELL_WIDTH * (index + 1)}" for index in range(len(row)))
|
|
cells = "".join(f"\\pard\\intbl {_rtf_escape(cell)}\\cell " for cell in row)
|
|
return "\\trowd\\trgaph108" + borders + "\n" + cells + "\\row\n"
|
|
|
|
|
|
# --- the three rtf VARIANTS round 10 added ------------------------------------
|
|
#
|
|
# WHY THEY EXIST. Round 9 measured the `rtf` row on ONE document and it came
|
|
# back at 0 of 0 declared headings, 0 concepts, 1368 of 1368 characters in no
|
|
# segment. A repair proposed on N = 1 is a repair fitted to one file, so round
|
|
# 10's order required N >= 3 before any rule was written. Three lawful sources
|
|
# were offered; this is the first of them, and it is the one this repository's
|
|
# committed-fixture policy allows: hand-laid here, in the same file, with the
|
|
# fasit written BEFORE the measurement.
|
|
#
|
|
# WHAT EACH ONE VARIES, and it is one axis each:
|
|
#
|
|
# -fet-alene the title is bold at BODY size, with no point-size change
|
|
# at all. Variant A (`krav-rikt-tekstformat.rtf`) sets its
|
|
# title bold AND at 16pt, so without this document a rule
|
|
# reading "bold" could be passing on the size instead.
|
|
# -fet-i-avsnitt a bold PHRASE inside a running sentence, which must NOT
|
|
# become a boundary. The document's own known-negative.
|
|
# -stil the container DECLARES heading styles (`\\s1`, `\\s2`) in a
|
|
# stylesheet. This is the control for the whole repair, and
|
|
# it is a control that FAILED in an informative direction:
|
|
# measured, the vendored converter discards the style and
|
|
# emits the same bold line, so "read the declared style"
|
|
# is not a route that exists for `rtf`. The fixture is kept
|
|
# precisely because it pins that.
|
|
#
|
|
# The bodies are deliberately short. These documents answer where a boundary
|
|
# is, not how much text survives -- variant A already carries the coverage and
|
|
# fidelity counts for the container.
|
|
|
|
RTF_VARIANT_TITLE = "Kravspesifikasjon for tunnelbelysning"
|
|
RTF_VARIANT_SECTION = "Luminanskrav per trafikklasse"
|
|
RTF_VARIANT_BODY_ONE = "Dokumentet samler kravene til belysning i vegtunneler over 500 meter."
|
|
RTF_VARIANT_BODY_TWO = "Terskelluminansen er 145 candela og gjelder i hele overgangssonen."
|
|
#: The bold RUN in `-fet-i-avsnitt`, set inside a sentence that continues past
|
|
#: it. Authored as emphasis, never as a title.
|
|
RTF_VARIANT_EMPHASIS = "fravik"
|
|
|
|
|
|
def _rtf_document(paragraphs: list[str]) -> bytes:
|
|
"""Wrap hand-written paragraph bodies in the minimal rtf container."""
|
|
header = "{\\rtf1\\ansi\\ansicpg1252\\deff0{\\fonttbl{\\f0\\froman Times New Roman;}}"
|
|
return (header + "".join(paragraphs) + "}").encode("ascii")
|
|
|
|
|
|
def rtf_bold_only_bytes() -> bytes:
|
|
"""Title and section name bold at BODY size -- no point-size change."""
|
|
return _rtf_document(
|
|
[
|
|
"\\pard\\sa180\\b " + _rtf_escape(RTF_VARIANT_TITLE) + "\\b0\\par\n",
|
|
"\\pard\\sa180 " + _rtf_escape(RTF_VARIANT_BODY_ONE) + "\\par\n",
|
|
"\\pard\\sa180\\b " + _rtf_escape(RTF_VARIANT_SECTION) + "\\b0\\par\n",
|
|
"\\pard\\sa180 " + _rtf_escape(RTF_VARIANT_BODY_TWO) + "\\par\n",
|
|
]
|
|
)
|
|
|
|
|
|
def rtf_bold_inside_paragraph_bytes() -> bytes:
|
|
"""One bold title, and one bold phrase mid-sentence that is not a title."""
|
|
sentence = (
|
|
"Avvik fra kravene krever soknad om "
|
|
+ "\\b "
|
|
+ _rtf_escape(RTF_VARIANT_EMPHASIS)
|
|
+ "\\b0 "
|
|
+ " fra byggherren for arbeidet starter."
|
|
)
|
|
return _rtf_document(
|
|
[
|
|
"\\pard\\sa180\\b " + _rtf_escape(RTF_VARIANT_TITLE) + "\\b0\\par\n",
|
|
"\\pard\\sa180 " + _rtf_escape(RTF_VARIANT_BODY_ONE) + "\\par\n",
|
|
"\\pard\\sa180 " + sentence + "\\par\n",
|
|
]
|
|
)
|
|
|
|
|
|
def rtf_styled_bytes() -> bytes:
|
|
"""The container DECLARES heading styles. The converter discards them."""
|
|
stylesheet = "{\\stylesheet{\\s0 Normal;}{\\s1\\b\\fs32 heading 1;}{\\s2\\b\\fs28 heading 2;}}"
|
|
return _rtf_document(
|
|
[
|
|
stylesheet,
|
|
"\\pard\\s1\\b\\fs32 " + _rtf_escape(RTF_VARIANT_TITLE) + "\\b0\\fs24\\par\n",
|
|
"\\pard\\s0\\sa180 " + _rtf_escape(RTF_VARIANT_BODY_ONE) + "\\par\n",
|
|
"\\pard\\s2\\b\\fs28 " + _rtf_escape(RTF_VARIANT_SECTION) + "\\b0\\fs24\\par\n",
|
|
"\\pard\\s0\\sa180 " + _rtf_escape(RTF_VARIANT_BODY_TWO) + "\\par\n",
|
|
]
|
|
)
|
|
|
|
|
|
def rtf_bytes() -> bytes:
|
|
body = [
|
|
"{\\rtf1\\ansi\\ansicpg1252\\deff0",
|
|
"{\\fonttbl{\\f0\\froman Times New Roman;}}",
|
|
"\\pard\\sa180\\b\\fs32 " + _rtf_escape(TITLE) + "\\b0\\fs24\\par\n",
|
|
"\\pard\\sa180 " + _rtf_escape(INTRO) + "\\par\n",
|
|
]
|
|
body.extend(_rtf_row(row) for row in PAIRS)
|
|
body.append("\\pard\\sa180 " + _rtf_escape(GRID_CAPTION) + "\\par\n")
|
|
body.extend(_rtf_row(row) for row in GRID)
|
|
body.append("}")
|
|
return "".join(body).encode("ascii")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
RTF_OUT.mkdir(parents=True, exist_ok=True)
|
|
written = {
|
|
OUT / "krav-presentasjon.pptx": build_container(pptx_parts()),
|
|
OUT / "krav-tekstdokument.odt": build_container(odt_parts(), stored_first="mimetype"),
|
|
OUT / "krav-rikt-tekstformat.rtf": rtf_bytes(),
|
|
RTF_OUT / "krav-rikt-tekstformat-fet-alene.rtf": rtf_bold_only_bytes(),
|
|
RTF_OUT / "krav-rikt-tekstformat-fet-i-avsnitt.rtf": rtf_bold_inside_paragraph_bytes(),
|
|
RTF_OUT / "krav-rikt-tekstformat-stil.rtf": rtf_styled_bytes(),
|
|
}
|
|
for path, payload in sorted(written.items()):
|
|
path.write_bytes(payload)
|
|
print(f"wrote {path.parent.name}/{path.name} ({len(payload)} bytes)")
|