Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a7b75e3
chore(diagram): open the headless export and delivery slice
HansBug Jul 30, 2026
bad19f0
feat(diagram): export SVG and PNG synchronously, bounded by output li…
HansBug Jul 30, 2026
a635dc0
feat(diagram): export vector PDF through the shared writer and a DOM …
HansBug Jul 30, 2026
61db75c
fix(diagram): register the PDF writer asset with the packaging gates
HansBug Jul 30, 2026
32a2e7c
feat(cli): export SVG, PNG and PDF from the diagram command
HansBug Jul 30, 2026
e04276a
feat(diagram): refuse oversized exports on the browser path too
HansBug Jul 30, 2026
92649d0
test(diagram): exercise synchronous export over the whole canonical c…
HansBug Jul 30, 2026
ed4c866
docs(visualization): document synchronous export and its size limits
HansBug Jul 30, 2026
d5bec00
test(diagram): gate the notebook representation end to end
HansBug Jul 30, 2026
1436265
test(diagram): require the browser and synchronous exports to agree
HansBug Jul 30, 2026
479e4b1
test(diagram): export every format from the installed wheel
HansBug Jul 30, 2026
492222a
fix(diagram): call the browser refusal, and correct four review findings
HansBug Jul 30, 2026
c877dfe
test(diagram): drive parity from the corpus, and self-check both new …
HansBug Jul 30, 2026
11f398f
fix(ci): give the installed-export harness a job that can reach it
HansBug Jul 31, 2026
74d6902
fix(ci): actually wire the export job into the release-test gate
HansBug Jul 31, 2026
edc7a0d
fix(editor): say so when a host cannot expand the exported SVG
HansBug Jul 31, 2026
f554ab6
fix(test): stop the interrupt probes from disabling coverage measurement
HansBug Jul 31, 2026
661b96e
fix(diagram): close the four gaps the second review found in the firs…
HansBug Jul 31, 2026
d4030ee
fix(diagram): record the PDF writer's provenance and its bundle inven…
HansBug Jul 31, 2026
dc82a13
fix(diagram): compare every fill value, and refuse an empty colour set
HansBug Jul 31, 2026
7e5795c
fix(diagram): validate the installed PDF dependency, not just its pre…
HansBug Jul 31, 2026
0a1df1b
fix(diagram): validate all three PDF dependencies the same way
HansBug Jul 31, 2026
ed7f2c2
fix(diagram): keep the raster fallbacks out of the PDF writer bundle
HansBug Jul 31, 2026
f3b3468
refactor(diagram): anchor both bundle scans the same way
HansBug Jul 31, 2026
89b1368
fix(diagram): stop to_pdf drawing white halos over transition labels
HansBug Jul 31, 2026
64e0e18
fix(diagram): close the residual order and encoding gaps the sixth re…
HansBug Jul 31, 2026
399c773
fix(tooling): close the nested-install and unread-stream gate bypasses
HansBug Jul 31, 2026
2285092
test(diagram): cover the command's documented paths and refusals
HansBug Jul 31, 2026
b8ac693
test(entry): cover JSON on standard output and a binary input
HansBug Jul 31, 2026
3e19c1d
test(entry): stop claiming an ordering the assertions cannot see
HansBug Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/release_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,52 @@ jobs:
${{ runner.temp }}/java-version.txt
${{ runner.temp }}/plantuml-version.txt

fresh_wheel_export:
# Separate from the two self-check jobs above because those deliberately have
# no checkout: they exist to prove the artifact works with nothing else
# present. This one needs the harness from the tree, so it keeps the isolation
# that matters a different way -- the harness never imports ``pyfcstm``, it only
# runs the installed command line, and it runs from outside the repository so
# the source tree cannot shadow the installed package.
name: Export every format from the installed wheel
runs-on: ubuntu-22.04
needs: source_release
steps:
- name: Checkout the export harness
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Download only the built wheel artifact
uses: actions/download-artifact@v4
with:
name: build-artifacts-source-pack
path: ${{ runner.temp }}/pyfcstm-package-artifact
- name: Install the wheel with its optional runtime and export
shell: bash
env:
PYTHONPATH: ''
PYTHONNOUSERSITE: '1'
run: |
set -euo pipefail
wheel=$(find "$RUNNER_TEMP/pyfcstm-package-artifact" -maxdepth 1 -type f -name '*.whl' -print -quit)
test -n "$wheel"
harness="$PWD/tools/check_diagram_installed_export.py"
test -f "$harness"
venv="$RUNNER_TEMP/pyfcstm-export-venv"
rm -rf "$venv"
python -m venv "$venv"
"$venv/bin/python" -m pip install --upgrade pip
"$venv/bin/python" -m pip install "${wheel}[viz]"
# Run from outside the checkout: an installed console script does not put
# the working directory on ``sys.path``, but keeping the tree out of the
# way removes the question entirely.
cd "$RUNNER_TEMP"
python "$harness" --command "$venv/bin/pyfcstm" --repeat 2

release_docs_pdf:
name: Bilingual Docs PDF
runs-on: ubuntu-24.04
Expand Down Expand Up @@ -1217,6 +1263,7 @@ jobs:
- source_release
- fresh_wheel_selfcheck_linux
- fresh_wheel_selfcheck
- fresh_wheel_export
- fresh_sdist_selfcheck
- release_cli
- fresh_release_cli_selfcheck
Expand All @@ -1227,6 +1274,7 @@ jobs:
SOURCE_RESULT: ${{ needs.source_release.result }}
WHEEL_LINUX_RESULT: ${{ needs.fresh_wheel_selfcheck_linux.result }}
WHEEL_NONLINUX_RESULT: ${{ needs.fresh_wheel_selfcheck.result }}
WHEEL_EXPORT_RESULT: ${{ needs.fresh_wheel_export.result }}
SDIST_RESULT: ${{ needs.fresh_sdist_selfcheck.result }}
CLI_BUILD_RESULT: ${{ needs.release_cli.result }}
CLI_FRESH_RESULT: ${{ needs.fresh_release_cli_selfcheck.result }}
Expand All @@ -1242,6 +1290,7 @@ jobs:
"source=$SOURCE_RESULT" \
"wheel_linux=$WHEEL_LINUX_RESULT" \
"wheel_nonlinux=$WHEEL_NONLINUX_RESULT" \
"wheel_export=$WHEEL_EXPORT_RESULT" \
"sdist=$SDIST_RESULT"; do
echo "$pair"
test "${pair#*=}" = success
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,23 @@ jobs:
shell: bash
run: |
make diagram_browser_check
- name: Run synchronous export gate over the canonical corpus
# Separate from ``diagram_assets_verify`` because it exports every corpus
# case three times at three scales, which is minutes rather than seconds.
if: ${{ matrix.python-version == '3.11' }}
shell: bash
run: |
make diagram_headless_check
- name: Run notebook representation gate
if: ${{ matrix.python-version == '3.11' }}
shell: bash
run: |
make diagram_notebooks_check
- name: Require the two export paths to agree
if: ${{ matrix.python-version == '3.11' }}
shell: bash
run: |
make diagram_browser_headless_check

template_suite_gate:
name: template-suite-gate
Expand Down
26 changes: 24 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: docs docs_en docs_zh docs_pdf docs_pdf_en docs_pdf_zh test unittest template_unittest resource antlr antlr_build fcstm_antlr_build fbmcq_antlr_build build build_info build_info_cli package clean build_assets build_assets_clean diagram_assets_check diagram_rendering_check diagram_browser_check diagram_contract_check diagram_data_check diagram_options_check diagram_csp_check diagram_parity_check diagram_reference_check diagram_engine_floor diagram_provenance_check diagram_assets_verify diagram_package_check diagram_corpus docs_auto todos_auto tests_auto rst_auto sha256 jsfcstm jsfcstm_clean vscode vscode_clean vscode_install vscode_uninstall logos logos_clean app_icons app_icons_clean help tpl tpl_clean templates_package template_packaging_check template_source_install_check docs_terminology_check test_boundary_check
.PHONY: docs docs_en docs_zh docs_pdf docs_pdf_en docs_pdf_zh test unittest template_unittest resource antlr antlr_build fcstm_antlr_build fbmcq_antlr_build build build_info build_info_cli package clean build_assets build_assets_clean diagram_assets_check diagram_rendering_check diagram_browser_check diagram_contract_check diagram_data_check diagram_options_check diagram_csp_check diagram_parity_check diagram_reference_check diagram_export_limits_check diagram_headless_check diagram_notebooks_check diagram_browser_headless_check diagram_engine_floor diagram_provenance_check diagram_assets_verify diagram_package_check diagram_corpus docs_auto todos_auto tests_auto rst_auto sha256 jsfcstm jsfcstm_clean vscode vscode_clean vscode_install vscode_uninstall logos logos_clean app_icons app_icons_clean help tpl tpl_clean templates_package template_packaging_check template_source_install_check docs_terminology_check test_boundary_check

PYTHON := $(shell which python)

Expand Down Expand Up @@ -98,6 +98,10 @@ help:
@echo " make diagram_data_check - Verify parsed, imported and programmatic data parity"
@echo " make diagram_options_check - Verify option and view-state mapping parity"
@echo " make diagram_csp_check - Verify the standalone HTML policy and embedded resources"
@echo " make diagram_export_limits_check - Compare export size limits across both export paths"
@echo " make diagram_headless_check - Exercise synchronous SVG/PNG/PDF export over the canonical corpus"
@echo " make diagram_notebooks_check - Check the notebook representation is stored, round-tripped and self-contained"
@echo " make diagram_browser_headless_check - Require the browser download and the synchronous export to agree"
@echo " make diagram_assets_verify - Run provenance, runtime, contract, and visual asset gates"
@echo " make diagram_parity_check DIAGRAM_REFERENCE=/abs/path/reference.json"
@echo " make diagram_reference_check - Verify reference archive retry behavior"
Expand Down Expand Up @@ -311,10 +315,28 @@ diagram_engine_floor: build_assets
diagram_provenance_check:
$(PYTHON) tools/check_diagram_provenance.py

diagram_browser_headless_check: build_assets
$(PYTHON) tools/check_diagram_browser_headless.py --check
$(PYTHON) tools/check_diagram_browser_headless.py --all-cases --formats svg,png,pdf

diagram_notebooks_check: build_assets
$(PYTHON) tools/check_diagram_notebooks.py --check
$(PYTHON) tools/check_diagram_notebooks.py

diagram_headless_check: build_assets
$(PYTHON) tools/check_diagram_headless.py --check
$(PYTHON) tools/check_diagram_headless.py \
--all-cases --formats svg,png,pdf --repeat 3 \
--png-scales 1,2,4 --pdf-require-zero-images --pdf-page-size-match

diagram_export_limits_check:
$(PYTHON) tools/check_diagram_export_limits.py --check
$(PYTHON) tools/check_diagram_export_limits.py

diagram_reference_check:
$(PYTHON) tools/fetch_diagram_reference.py --check

diagram_assets_verify: diagram_assets_check diagram_docstring_check diagram_rendering_check diagram_contract_check diagram_data_check diagram_options_check diagram_csp_check diagram_parity_check diagram_engine_floor diagram_provenance_check diagram_reference_check
diagram_assets_verify: diagram_assets_check diagram_docstring_check diagram_rendering_check diagram_contract_check diagram_data_check diagram_options_check diagram_csp_check diagram_export_limits_check diagram_parity_check diagram_engine_floor diagram_provenance_check diagram_reference_check

diagram_package_check: package

Expand Down
63 changes: 62 additions & 1 deletion docs/source/api_doc/diagram/engine.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,42 @@ pyfcstm.diagram.engine
.. automodule:: pyfcstm.diagram.engine


MAX\_EXPORT\_SCALE
-----------------------------------------------------

.. autodata:: MAX_EXPORT_SCALE


MAX\_EXPORT\_EDGE\_PX
-----------------------------------------------------

.. autodata:: MAX_EXPORT_EDGE_PX


MAX\_EXPORT\_PIXELS
-----------------------------------------------------

.. autodata:: MAX_EXPORT_PIXELS


MAX\_EXPORT\_RAW\_RGBA\_BYTES
-----------------------------------------------------

.. autodata:: MAX_EXPORT_RAW_RGBA_BYTES


MAX\_EXPORT\_PNG\_BYTES
-----------------------------------------------------

.. autodata:: MAX_EXPORT_PNG_BYTES


MAX\_EXPORT\_TEXT\_BYTES
-----------------------------------------------------

.. autodata:: MAX_EXPORT_TEXT_BYTES


DiagramError
-----------------------------------------------------

Expand All @@ -30,6 +66,13 @@ DiagramRenderError
.. autoclass:: DiagramRenderError


DiagramRenderLimitError
-----------------------------------------------------

.. autoclass:: DiagramRenderLimitError
:members: __init__


DiagramEngineMetadataError
-----------------------------------------------------

Expand All @@ -52,4 +95,22 @@ DiagramAssetEngine
-----------------------------------------------------

.. autoclass:: DiagramAssetEngine
:members: __init__,render_svg,render_png,expand_svg
:members: __init__,render_svg,render_png,render_pdf,expand_svg


check\_export\_scale
-----------------------------------------------------

.. autofunction:: check_export_scale


check\_export\_size
-----------------------------------------------------

.. autofunction:: check_export_size


check\_export\_bytes
-----------------------------------------------------

.. autofunction:: check_export_bytes
102 changes: 102 additions & 0 deletions docs/source/reference/visualization_options/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,108 @@ positionally replaces the whole object instead. Missing or unusable packaged
viewer/WASM/font assets raise ``DiagramAssetError`` with development recovery
guidance (``make build_assets``) or the project issue URL for installed packages.

Synchronous export and its size limits
--------------------------------------

``to_svg()``, ``to_png(scale=...)`` and ``to_pdf()`` export without a browser.
They need the optional rendering runtime that ``pip install pyfcstm[viz]``
provides; without it each raises ``DiagramUnavailableError`` naming that extra.
``save()`` routes a ``.svg``, ``.png`` or ``.pdf`` suffix to them, and the CLI
accepts the same three suffixes plus ``--scale``.

.. list-table:: Synchronous export surface
:header-rows: 1

* - Call
- Returns
- Notes
* - ``Diagram.to_svg()``
- ``str``
- The expanded form: glyphs and arrow heads are already paths, so the
document carries no ``<text>``, ``<marker>`` or font dependency and renders
identically where none of this project's fonts are installed. The
renderer's raw canonical SVG is an internal intermediate and is not what
this returns.
* - ``Diagram.to_png(scale=1.0)``
- ``bytes``
- Rasterised through the pinned resvg backend, opaque, at ``ceil(size *
scale)`` pixels.
* - ``Diagram.to_pdf()``
- ``bytes``
- One page sized to the diagram, drawn as vectors with no image object.
Text is outlines, so the document is **not searchable** -- that is the cost
of it rendering without this project's fonts.
* - ``Diagram._repr_svg_()``
- ``str`` or ``None``
- Notebook representation, the same expanded SVG. Returns ``None`` when the
optional runtime is absent, because an exception raised from a repr hook
replaces the whole cell output with a traceback.

.. note::
The viewer's own PNG download rasterises at a fixed 2x, while
``to_png()`` defaults to 1x. Comparing a downloaded file with an
API-produced one therefore shows a factor-of-two difference in pixels that is
two different requests rather than a disagreement; pass ``scale=2`` to compare
like with like.

Every export is bounded. The limits are checked in Python before the rasteriser
or the PDF writer is reached, so an impossible request is named rather than
discovered by exhausting memory.

.. list-table:: Export size limits
:header-rows: 1

* - Limit
- Value
- Raised on breach
* - ``scale``
- ``0 < scale <= 4``
- ``ValueError``
* - Scaled width and height, each
- ``<= 16384`` px
- ``DiagramRenderLimitError``
* - Scaled pixel count
- ``<= 16777216``
- ``DiagramRenderLimitError``
* - Raw RGBA buffer
- ``<= 67108864`` bytes, which is the pixel cap times four
- Reported as the pixel limit
* - Encoded PNG
- ``<= 33554432`` bytes
- ``DiagramRenderLimitError``
* - Encoded SVG or PDF
- ``<= 67108864`` bytes
- ``DiagramRenderLimitError``

``DiagramRenderLimitError`` carries a ``limit_name``, and the only values it takes
are ``edge``, ``pixels``, ``png``, ``pdf`` and ``svg``. There is no ``raw_rgba``: the raw
buffer is four bytes per pixel and its bound is the pixel bound times four, so any
request large enough to reach it has already been refused as a pixel-count
breach. The figure is listed above because the documented limit set names a
buffer size, not because it is a separate boundary.

The encoded-size limits are enforced on the Python export path, where the bytes
are produced. The browser download enforces the scale, edge and pixel limits; it
does not weigh its own output.

``DiagramRenderLimitError`` is a sibling of ``DiagramRenderError``, not a
subclass. The two describe different situations with different remedies: a render
failure means the renderer was asked to do something and could not, while a limit
failure means it was never asked. Lowering ``scale`` fixes the latter and nothing
else, so ``except DiagramRenderError`` must not absorb it.

The message names the original size, the scaled size, the limit that fired and
what to change, because "too large" alone does not tell a caller which scale would
have fitted.

The browser download enforces the same product limits and refuses past them.
Separately, it clamps at the limits a browser canvas actually has
(``RASTER_MAX_SIDE``, ``RASTER_MAX_AREA``), which are not product policy: a tall
diagram at 2x once produced a null blob and took the SVG and PDF download down
with it. Every product limit is stricter than the host limit it shadows, so the
refusal always fires first and the clamp is a defensive second layer ordinary
input never reaches.

Renderer and file options
-------------------------

Expand Down
Loading
Loading