Skip to content

feat(svm): write the svg through a writer that escapes, and log what we drop - #779

Merged
andiwand merged 4 commits into
mainfrom
feat/svm-svg-writer
Aug 30, 2026
Merged

feat(svm): write the svg through a writer that escapes, and log what we drop#779
andiwand merged 4 commits into
mainfrom
feat/svm-svg-writer

Conversation

@andiwand

@andiwand andiwand commented Aug 30, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Stage 1 of #772 — the infrastructure the rest of the work stands on. The
staged plan is written down in src/odr/internal/svm/PLAN.md; this PR is the
base of the stack.

The problem

svm_to_svg.cpp wrote its markup straight into the stream. Two consequences:

  • Text was not escaped. write_text did out << text. An &, < or >
    in a chart label makes the svg malformed, and a malformed svg renders as
    nothing — the browser drops the whole image, not the one label. (Improve SVM to SVG conversion #772
    defect 1.)
  • Nothing was logged. Every unhandled action carried a // TODO log, and
    write_image_src caught every exception and fell back to emitting the raw
    .svm bytes as a data url that no browser renders. A file we drew correctly
    and a file we drew as a blank rectangle looked exactly alike. (Improve SVM to SVG conversion #772 defect
    10.)

What is here

  • svg::SvgWriter (internal/svg/svg_writer.*), the counterpart to
    html::HtmlWriter: elements, attributes, style declarations and text, all
    escaped, with the style attribute accumulated per element so attributes and
    style can be written in any order. A ; in a style value is dropped rather
    than allowed to open a declaration of its own.
  • One escaper and one number formatter, shared with html. The escape lives
    in util::xml::escape_text / escape_attribute — one pass, dropping the
    control characters xml 1.0 cannot carry — and html::escape_attribute, four
    replace_all passes over a by-value string, is gone in favour of it.
    html::escape_text stays in html: its &nbsp; and &emsp; are
    presentation, not escaping, and &nbsp; is undefined in xml. Numbers go
    through util::number::to_string_significant, which already documented
    "never in scientific notation, which CSS and SVG lengths do not accept"; the
    classic locale it needed (a german one writes 1,5 into a coordinate) moved
    in there, so Measure::to_string gets it too.
  • A logger through the translator. Translator::svg becomes
    svm::translate_to_svg(file, out, logger) and reports every action it skips
    by name (action_type_name in svm_format). Getting a logger there needed
    one: abstract::HtmlService gained logger(), so a view reaches the sink
    its service was built with, and WritingState carries it down to
    translate_image_src.
  • write_image_src asks the file type before trying an svm translation,
    instead of attempting one on every image and catching. That is what makes a
    failure worth a warning rather than noise on every png.
  • write_style(out, context, int) with its magic 0/1/2 is an enum now
    (Improve SVM to SVG conversion #772 defect 9).
  • Docs: svm/README.md keeps the feature checklist, now checked against
    the code and completed — the shapes, fills, clipping and state handling that
    were never listed, and the references it was missing. svm/AGENTS.md (the
    format, the conventions) and svm/PLAN.md (the stages) are new; a row in the
    root AGENTS.md; a section in svg/AGENTS.md for the writer.

Tests

svm_test.cpp builds its input as bytes, inline (SvmBuilder), so an
action gets a test without a fixture — the harness the following stages need.
Six new cases: the empty file, a rectangle, text, text escaping, attribute
escaping, and that an unimplemented action is skipped by its own length so the
one after it still reads.

Output is unchanged

Rendered the four svm fixtures and diffed the decoded svg against main's:

fixture before after
odr-public/svm/chart-1.svm 6658 B identical
odr-public/svm/table-1.svm 9358 B identical
odr-private/svm/test.svm 18643 B identical
odr-private/svm/Vyplaty.svm 21913 B identical

The formatter can only part from operator<< where a value would print with
an exponent — |v| ≥ 1e6, or 0 < |v| < 1e-4 — and no coordinate in the four
fixtures is in that range. Where it does bite it is the fix: 1051938 rather
than 1.05194e+06, which is not a css length at all.

The full suite passes (1285 tests), HtmlOutputTests included, so the shared
escaper changes no reference rendering either. Decoding all 1144 generated
svgs in the reference output shows none of them currently malformed, so the
escaping fix changes no rendering today — the bug is latent in the corpus, not
absent from the wild.

Known gap, recorded rather than fixed

Escaping is only half of #772 defect 1. read_string_with_encoding hands back
the file's own bytes for every encoding but UCS2, so a latin-1 label emits
invalid utf-8 and an xml parser refuses the document just as hard as an
unescaped & made it. README.md records it and stage 3 of PLAN.md picks
it up.

andiwand and others added 4 commits August 30, 2026 10:55
…we drop

The svm to svg translator wrote markup straight into the stream, which means
text went in unescaped: an `&`, `<` or `>` in a chart label makes the svg
malformed, and a malformed svg renders as nothing at all - the whole image,
not the one label. It also had no logger, so an action we do not implement,
and the failure that falls back to emitting the raw `.svm` bytes as a data url
no browser renders, were both silent.

`svg::SvgWriter` is the counterpart to `html::HtmlWriter`: elements,
attributes, style declarations and text, escaped, plus number formatting that
does not depend on the stream's locale or precision. `svm_to_svg.cpp` writes
through it, `Translator::svg` becomes `svm::translate_to_svg` and takes a
`Logger`.

Threading that logger down needed one: `abstract::HtmlService` gained
`logger()`, so views reach the sink the service was built with, and
`WritingState` carries it to `translate_image_src`.

`write_image_src` now asks the file type before trying an svm translation
rather than attempting one on every image and catching, which is what makes a
failure worth logging.

Tests build their metafile as bytes inline, so an action is testable without a
fixture. Output on the four svm fixtures is unchanged except for coordinate
precision, which improves: `std::to_chars` with three decimals where the
stream's six significant digits used to round 10519.375 to 10519.4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmCr22NW6wPQKiQidk96bq
`svg::format_number` was a second implementation of
`util::number::to_string_significant`, whose own doc comment already says
"never in scientific notation, which CSS and SVG lengths do not accept". The
two agree on every value the svg tests cover; they parted only below `1e-4`,
where the stream-based one collapsed `1e-07` to `0`. The classic locale the
svg writer wanted moves into `number_util`, so `Measure::to_string` gets it
too.

`svg::escape_text` / `escape_attribute` and `html::escape_attribute` were the
same escape written twice - the html one as four `replace_all` passes over a
by-value string. Both are now `util::xml::escape_text` /
`escape_attribute`, one pass, dropping the control characters xml 1.0 cannot
carry. `html::escape_text` stays in `html`: its `&nbsp;` and `&emsp;` are
presentation, not escaping.

Also stops a `;` in a style value from opening a declaration of its own. It
is dropped before escaping, which writes `;` of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XQpLmJpJ87qbKoG8B7kbLY
The catch narrowed to `std::exception` to report `what()`, which let anything
else past `translate_image_src(const ImageFile &)`, where nothing catches it.
A second catch keeps the old guarantee and still says the image is gone.

Also widens the skip length explicitly, as its sibling call already did, and
trims two comments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XQpLmJpJ87qbKoG8B7kbLY
`README.md` comes back rather than being replaced by `AGENTS.md` and
`PLAN.md`: it is the checklist of what the translator does and does not draw,
now checked against the code and extended with the shapes, fills, clipping and
state handling that were never listed, plus the references completed with
`SvmReader.cxx`, `SvmConverter.cxx`, `svgwriter.cxx` and the ONLYOFFICE spec.

It also records the half of #772's escaping defect that escaping does not
fix: `read_string_with_encoding` returns the file's own bytes for every
encoding but `UCS2`, so a latin-1 label emits invalid utf-8 and costs the
image exactly as an unescaped `&` did. Stage 3 in `PLAN.md` picks it up.

The comments this branch added are cut back to the house standard - no
restatement of the code, no narrative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XQpLmJpJ87qbKoG8B7kbLY
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant