fix(import): emit extractor output as UTF-8 on Windows (#122)

* fix(import): emit extractor digests as UTF-8

* fix(import): limit UTF-8 setup to CLI entrypoints

* test(import): normalize cross-platform newline parity
This commit is contained in:
Cathryn Lavery
2026-08-19 17:49:34 -07:00
committed by GitHub
parent 8154ec97bf
commit cc2f51f3fd
7 changed files with 161 additions and 3 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "diagram-design", "name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.5.14", "version": "2.5.15",
"author": { "author": {
"name": "Cathryn Lavery", "name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery" "url": "https://github.com/cathrynlavery"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "diagram-design", "name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.5.14", "version": "2.5.15",
"author": { "author": {
"name": "Cathryn Lavery", "name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery" "url": "https://github.com/cathrynlavery"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "diagram-design", "name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.5.14", "version": "2.5.15",
"author": { "author": {
"name": "Cathryn Lavery", "name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery" "url": "https://github.com/cathrynlavery"
+73
View File
@@ -10,8 +10,11 @@ Exit 0 only when all gates pass. Intended for local/PR checks alongside:
from __future__ import annotations from __future__ import annotations
import base64 import base64
import contextlib
import importlib.util import importlib.util
import io
import json import json
import os
import re import re
import struct import struct
import subprocess import subprocess
@@ -43,6 +46,10 @@ def ok(msg: str) -> None:
print(f"OK: {msg}") print(f"OK: {msg}")
def normalize_newlines(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")
def run_extract(args: list[str]) -> str: def run_extract(args: list[str]) -> str:
proc = subprocess.run( proc = subprocess.run(
[sys.executable, str(EXTRACT), *args], [sys.executable, str(EXTRACT), *args],
@@ -56,6 +63,71 @@ def run_extract(args: list[str]) -> str:
return proc.stdout return proc.stdout
def check_legacy_stdout_encoding(tmp: Path) -> None:
source = tmp / "unicode-stdout.drawio"
source.write_text(
"""<mxfile><diagram name="日本語">
<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>
<mxCell id="2" value="登录&lt;br&gt;続行 ⇒ résumé" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="120" height="60" as="geometry"/>
</mxCell></root></mxGraphModel></diagram></mxfile>""",
encoding="utf-8",
)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "cp1252"
env["PYTHONUTF8"] = "0"
process = subprocess.run(
[sys.executable, str(EXTRACT), str(source)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
if process.returncode != 0:
fail(
"draw.io extractor failed with legacy stdout encoding: "
+ process.stderr.decode("utf-8", errors="replace").strip()
)
try:
output = process.stdout.decode("utf-8", errors="strict")
except UnicodeDecodeError as error:
fail(f"draw.io extractor did not emit UTF-8 stdout: {error}")
for needle in ("日本語", "登录", "続行 ⇒ résumé", "⏎"):
if needle not in output:
fail(f"UTF-8 draw.io digest lost {needle!r}: {output!r}")
if "�" in output:
fail("UTF-8 draw.io digest contains a replacement character")
destination = tmp / "unicode-stdout.md"
file_process = subprocess.run(
[sys.executable, str(EXTRACT), str(source), "--out", str(destination)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
if file_process.returncode != 0:
fail("draw.io --out failed under a legacy Windows encoding")
file_output = destination.read_text(encoding="utf-8")
if normalize_newlines(file_output) != normalize_newlines(output):
fail("draw.io --out no longer matches its UTF-8 stdout digest")
class CallerOwnedStdout(io.StringIO):
def __init__(self) -> None:
super().__init__()
self.reconfigured = False
def reconfigure(self, **_kwargs: object) -> None:
self.reconfigured = True
caller_stdout = CallerOwnedStdout()
extractor = load_extractor_module()
with contextlib.redirect_stdout(caller_stdout):
result = extractor.main([str(source)])
if result != 0 or caller_stdout.reconfigured:
fail("imported draw.io main() reconfigured its caller-owned stdout")
if "登录" not in caller_stdout.getvalue():
fail("imported draw.io main() did not write to its caller-owned stdout")
ok("draw.io stdout stays lossless UTF-8 under a legacy Windows encoding")
def load_extractor_module(): def load_extractor_module():
spec = importlib.util.spec_from_file_location("diagram_design_drawio_extract", EXTRACT) spec = importlib.util.spec_from_file_location("diagram_design_drawio_extract", EXTRACT)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
@@ -443,6 +515,7 @@ def main() -> int:
check_files() check_files()
check_parse_raw() check_parse_raw()
check_containers(tmp) check_containers(tmp)
check_legacy_stdout_encoding(tmp)
check_digest_escaping(tmp) check_digest_escaping(tmp)
check_security_and_limits(tmp) check_security_and_limits(tmp)
check_docs() check_docs()
+69
View File
@@ -8,8 +8,11 @@ command wiring. Exit 0 only when every gate passes.
from __future__ import annotations from __future__ import annotations
import contextlib
import importlib.util import importlib.util
import io
import json import json
import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -38,6 +41,10 @@ def ok(message: str) -> None:
print(f"OK: {message}") print(f"OK: {message}")
def normalize_newlines(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")
def invoke(args: list[str]) -> subprocess.CompletedProcess[str]: def invoke(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run( return subprocess.run(
[sys.executable, str(EXTRACT), *args], [sys.executable, str(EXTRACT), *args],
@@ -58,6 +65,67 @@ def run_extract(args: list[str]) -> str:
return process.stdout return process.stdout
def check_legacy_stdout_encoding(tmp: Path) -> None:
source = tmp / "unicode-stdout.mmd"
source.write_text(
'flowchart TD\nA["登录<br/>続行 ⇒"] --> B["résumé"]\n',
encoding="utf-8",
)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "cp1252"
env["PYTHONUTF8"] = "0"
process = subprocess.run(
[sys.executable, str(EXTRACT), str(source)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
if process.returncode != 0:
fail(
"Mermaid extractor failed with legacy stdout encoding: "
+ process.stderr.decode("utf-8", errors="replace").strip()
)
try:
output = process.stdout.decode("utf-8", errors="strict")
except UnicodeDecodeError as error:
fail(f"Mermaid extractor did not emit UTF-8 stdout: {error}")
for needle in ("登录", "続行 ⇒", "résumé", "⏎"):
if needle not in output:
fail(f"UTF-8 Mermaid digest lost {needle!r}: {output!r}")
if "�" in output:
fail("UTF-8 Mermaid digest contains a replacement character")
destination = tmp / "unicode-stdout.md"
file_process = subprocess.run(
[sys.executable, str(EXTRACT), str(source), "--out", str(destination)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
if file_process.returncode != 0:
fail("Mermaid --out failed under a legacy Windows encoding")
file_output = destination.read_text(encoding="utf-8")
if normalize_newlines(file_output) != normalize_newlines(output):
fail("Mermaid --out no longer matches its UTF-8 stdout digest")
class CallerOwnedStdout(io.StringIO):
def __init__(self) -> None:
super().__init__()
self.reconfigured = False
def reconfigure(self, **_kwargs: object) -> None:
self.reconfigured = True
caller_stdout = CallerOwnedStdout()
extractor = load_extractor_module()
with contextlib.redirect_stdout(caller_stdout):
result = extractor.main([str(source)])
if result != 0 or caller_stdout.reconfigured:
fail("imported Mermaid main() reconfigured its caller-owned stdout")
if "登录" not in caller_stdout.getvalue():
fail("imported Mermaid main() did not write to its caller-owned stdout")
ok("Mermaid stdout stays lossless UTF-8 under a legacy Windows encoding")
def expect_error(args: list[str], message: str) -> None: def expect_error(args: list[str], message: str) -> None:
process = invoke(args) process = invoke(args)
if process.returncode != 2 or message not in process.stderr: if process.returncode != 2 or message not in process.stderr:
@@ -708,6 +776,7 @@ def main() -> int:
check_shape_and_edge_vocabulary(tmp) check_shape_and_edge_vocabulary(tmp)
check_frontmatter(tmp) check_frontmatter(tmp)
check_markdown_and_grammars(tmp) check_markdown_and_grammars(tmp)
check_legacy_stdout_encoding(tmp)
check_sequence_grammar_forms(tmp) check_sequence_grammar_forms(tmp)
check_adversarial(tmp) check_adversarial(tmp)
check_errors_and_limits(tmp) check_errors_and_limits(tmp)
@@ -43,6 +43,13 @@ MAX_INPUT_BYTES = 32 * 1024 * 1024
MAX_XML_BYTES = 64 * 1024 * 1024 MAX_XML_BYTES = 64 * 1024 * 1024
def _configure_stdout_utf8() -> None:
"""Emit digests as UTF-8 even when Windows selects a legacy codepage."""
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8", errors="strict")
class PayloadTooLarge(ValueError): class PayloadTooLarge(ValueError):
"""Raised when compressed metadata expands beyond the supported limit.""" """Raised when compressed metadata expands beyond the supported limit."""
@@ -886,4 +893,5 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
_configure_stdout_utf8()
raise SystemExit(main()) raise SystemExit(main())
@@ -51,6 +51,13 @@ MARKDOWN_SUFFIXES = {".md", ".markdown", ".mdown", ".mkd"}
MERMAID_SUFFIXES = {".mmd", ".mermaid"} MERMAID_SUFFIXES = {".mmd", ".mermaid"}
def _configure_stdout_utf8() -> None:
"""Emit digests as UTF-8 even when Windows selects a legacy codepage."""
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8", errors="strict")
def _fail(message: str) -> NoReturn: def _fail(message: str) -> NoReturn:
print(f"mermaid_extract: {message}", file=sys.stderr) print(f"mermaid_extract: {message}", file=sys.stderr)
raise SystemExit(2) raise SystemExit(2)
@@ -1322,4 +1329,5 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
_configure_stdout_utf8()
raise SystemExit(main()) raise SystemExit(main())