Skip to content

Decode WMF and SVG images instead of detecting and skipping them - #183

Open
ikashapov wants to merge 1 commit into
nerdy-pro:mainfrom
ikashapov:issue-150-wmf-svg
Open

ikashapov wants to merge 1 commit into
nerdy-pro:mainfrom
ikashapov:issue-150-wmf-svg

Conversation

@ikashapov

Copy link
Copy Markdown
Contributor

Decode WMF and SVG images instead of detecting and skipping them

Closes #150. Based on main (b2c0174, v0.5.1); one commit, branch
issue-150-wmf-svg. Independent of the other open branches.

What renders now

Both formats used to be recognised as image parts and dropped — the space
reserved, nothing drawn, because Skia's built-in decoder reads neither.

WMF — render::wmf, built to render::emf's pattern

The issue named emf.rs as the model to follow, documented limits
included, and that is what this is: handle the single-embedded-bitmap
case, decline anything that needs full GDI record replay. Per [MS-WMF]:

  • the optional 22-byte placeable header (§2.3.2.3) is sniffed by its
    0x9AC6CDD7 key and skipped — standalone .wmf files carry it,
    clipboard/RTF metafiles don't, so both spellings parse;
  • META_HEADER (§2.3.2.2) is validated on its two fixed fields
    (HeaderSize = 9 words, version 0x0100/0x0300);
  • the record walk scans the four DIB carriers: META_DIBSTRETCHBLT and
    META_DIBBITBLT — matched on their low byte, as §2.1.1.1 requires, and
    each guarded by the spec's own discriminator for their DIB-less second
    forms (RecordSize == (RecordFunction >> 8) + 3) — plus
    META_STRETCHDIB and META_SETDIBTODEV. SRCCOPY / DIB_RGB_COLORS
    only, like the EMF twin.

The payload is the same DeviceIndependentBitmap object EMF carries —
[MS-EMF] defines its bitmap records by reference to the [MS-WMF] objects —
so the DIB decoding moved out of emf.rs into a shared render::dib,
and grew what clip-art metafiles actually contain:

  • palette-indexed 1/4/8-bpp depths (RGBQUAD tables, biClrUsed
    shortening, an out-of-table index painting black rather than sinking
    the bitmap);
  • the 12-byte BitmapCoreHeader (§2.2.2.2) with its RGBTRIPLE palette;
  • BI_JPEG / BI_PNG (§2.1.1.7), whose "pixel data" is one embedded
    stream handed to Skia's ordinary decoder;
  • and the packed-DIB split (header + colour table + pixels contiguous,
    §2.2.2.9) that WMF records use, next to EMF's separate-slices shape.

Both metafile paths gain all of it. RLE and 16-bpp decline — drawing
nothing beats drawing garbage. The placeable magic also joins
ImageFormat::detect_by_magic, so an extension-less WMF part classifies.

SVG — svgBlip parsed, the fallback outranked, resvg rasterizing

Word embeds SVG as an asvg:svgBlip extension
({96DAC541-7B7A-43D3-8B79-37D633B846F1}, [MS-ODRAWXML]) beside a
rasterized PNG the main blip points at — kept, in the spec's own words,
"for backward compatibility" with consumers that cannot draw SVG. This
renderer now can, so:

  • the extension parses into the model (Blip.svg_embed), tolerant of the
    other extensions Word writes into the same extLst;
  • it survives the header/footer relationship remapping (a header's SVG
    id must follow its part's namespace);
  • selection prefers it over the fallback — the reading LibreOffice 24.2
    chose too. Producers that point the main blip straight at an
    image/svg+xml part (pandoc without a rasterizer at hand) take the
    same decode path.

Why resvg and why a raster. Skia has its own SVG module, but
skia-safe's svg feature forfeits the prebuilt-binary cache on every
platform this project builds on — no published binary pairs svg with
embed-freetype, and none carries it without a GPU backend or webp in
tow — turning every cold build into a from-source Skia build. resvg
with default features off is pure Rust (~40 crates, seconds to build,
MIT/Apache). The raster is produced at the painter's existing
per-placement downsample target, so an SVG comes out crisp at exactly
--image-dpi and the size-keyed bitmap cache keeps differently-sized
placements apart; alpha stays premultiplied end to end. The one recorded
limit: <text> inside an SVG is not rendered — the text feature would
pull a second font stack (fontdb/harfrust) beside Skia's, and
Office-inserted SVGs are overwhelmingly icons and diagrams.

Tests and fixtures

  • test-files/wmf-image.docx + test-files/svg-image.docx (new, by
    scripts/make_issue150_fixtures.py, written red-first). The WMF wraps
    a hand-built placeable metafile around a 2×2 four-colour DIB. The SVG
    fixture makes the selection observable: the fallback PNG is blue, the
    svgBlip's SVG is red — which part was picked is the drawn colour — and
    a second picture carries a no-fallback direct-SVG blip.
  • tests/wmf_svg_images.rs pins the pipeline: the extension in the
    model, both parts' formats in the media table, both draw commands
    resolving to the SVG parts, and a converted PDF that embeds an
    /Image XObject where it used to embed none.
  • Unit tests: the WMF record offsets (both blt variants over one DIB —
    equality pins both transcriptions), the DIB-less-form discriminator,
    placeable/bare parity, non-SRCCOPY declines; the shared DIB decoder's
    palette packing (nibble and bit order pinned on asymmetric patterns),
    core header, BI_PNG passthrough, undersized-table behaviour, RLE
    decline; resvg rasterization at the display target, intrinsic size,
    transparency, malformed-input decline; and the EMF suite re-based onto
    the shared decoder unchanged in behaviour.
  • README: the WMF row flips ❌ → ⚠️ (same single-bitmap contract as EMF),
    the SVG row ❌ → ✅, and EMF's row records its widened DIB support.

cargo fmt --check, clippy --all-targets -- -D warnings,
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps and the full 45-target
test suite pass (macOS, pinned 1.95.0). New dependency: resvg 0.48,
default features off, raster-images + svgz only — no C code, no
build-script downloads, MSRV 1.85 against the repo's pinned 1.95.

@ikashapov
ikashapov force-pushed the issue-150-wmf-svg branch 6 times, most recently from 7dbf620 to 9057063 Compare September 12, 2026 09:48
@ikashapov
ikashapov force-pushed the issue-150-wmf-svg branch 2 times, most recently from 87eca00 to ecfa239 Compare September 18, 2026 02:49
Both formats were recognised as image parts and dropped — the space
reserved, nothing drawn, because Skia's built-in decoder reads neither.

WMF (issue nerdy-pro#150, [MS-WMF]): a new `render::wmf` mirrors `render::emf` —
handle the single-embedded-bitmap case, decline what needs GDI replay.
The optional placeable header is sniffed and skipped, the META_HEADER
validated, and the record list scanned for `META_DIBSTRETCHBLT`,
`META_DIBBITBLT` (each with the spec's own discriminator for their
DIB-less second forms), `META_STRETCHDIB` and `META_SETDIBTODEV`. The
payload is the same DeviceIndependentBitmap object EMF carries
(§2.2.2.9), so the DIB decoding moved out of `emf.rs` into a shared
`render::dib` — and grew what clip-art metafiles actually contain: the
palette-indexed 1/4/8-bpp depths, the 12-byte BitmapCoreHeader, and the
BI_JPEG/BI_PNG compressions, whose payload is one embedded stream handed
to Skia's ordinary decoder. Both metafile paths gain all of it. The
placeable magic also joins `ImageFormat::detect_by_magic`.

SVG: Word embeds SVG as an `asvg:svgBlip` extension beside a rasterized
PNG fallback the main blip points at — kept, per [MS-ODRAWXML]
"Pictures", *for backward compatibility* with consumers that cannot draw
SVG. The extension now parses (`Blip.svg_embed`), survives header/footer
rel remapping, and outranks the fallback at selection — the reading
LibreOffice 24.2 chose too. Producers that point the main blip straight
at an `image/svg+xml` part (pandoc without a rasterizer) work the same
way. Rendering is by resvg with default features off (pure Rust, ~40
crates): deliberately not skia-safe's own `svg` feature, which no
prebuilt Skia binary pairs with `embed-freetype` — enabling it would
turn every cold build into a from-source Skia build. The raster is
produced at the painter's existing per-placement downsample target, so
an SVG is crisp at exactly `--image-dpi` and the size-keyed bitmap
cache keeps differently-sized placements apart. `<text>` inside an SVG
is not rendered — the `text` feature would pull a second font stack
beside Skia's; recorded in `render::svg`'s module doc.

Fixtures (scripts/make_issue150_fixtures.py, red-first): a hand-built
placeable WMF wrapping a 2×2 four-colour DIB, and an SVG document whose
fallback PNG is blue while the svgBlip's SVG is red — the drawn colour
says which part was picked — plus a no-fallback direct-SVG blip.
`tests/wmf_svg_images.rs` pins parse → media → selection → an /Image
XObject in the converted PDF; pixel-exact decode assertions live in the
`render::dib`/`render::wmf`/`render::svg` unit tests.

Closes nerdy-pro#150
ikashapov added a commit to ikashapov/dxpdf that referenced this pull request Oct 14, 2026
One squashed commit carrying the tree of integration/2026-10-14, which was
previously a chain of ten merges ending at ac5787e (kept locally as
archive/integration-2026-10-14-merges). Base: upstream main 3a5c7ca.

Integrated, each at the head of its open upstream pull request:

- nerdy-pro#252  fix-layout-defects-228-232 (a40d5ce) — issues nerdy-pro#228–nerdy-pro#232: bookmark-only
        paragraphs, nested-table width, list instances over one abstract
        numbering, table width resolution (pct and tcW autofit), column
        balancing and separators
- nerdy-pro#183  issue-150-wmf-svg (6e5aa99) — WMF/EMF and SVG images
- nerdy-pro#184  issue-151-pgborders-docgrid (27204b3) — page borders, document grid
- nerdy-pro#186  issue-153-shaped-clusters (65739f6) — shaped text clusters
- nerdy-pro#187  issue-154-tracked-changes (6982b34) — tracked changes and comments
- nerdy-pro#185  issue-155-smartart-charts (3683223) — SmartArt and charts
- nerdy-pro#256  issue-253-del-instr-text (0656a52) — w:delInstrText tolerated
- nerdy-pro#257  issue-250-image-hyperlinks (cb5ce2b) — a:hlinkClick on pictures
- nerdy-pro#258  issue-188-font-absent-cost (9a63bf0) — no deep catalogue tier before
        the substitution table, no theme script-font preload
- nerdy-pro#259  issue-158-auto-hyphenation (9bfbae2) — w:autoHyphenation

Beyond the union of those branches, the integration itself changed:
- README keeps one row per feature after the merges; the matrix tally is
  recomputed from the rows.
- nerdy-pro#253's tests assert that a deleted field code is never drawn and never run
  text, because nerdy-pro#154 keeps w:del runs instead of dropping them at parse.
- nerdy-pro#250's push_link_over sits after nerdy-pro#151's resolve_line_metrics; nerdy-pro#158's
  HyphenationCtx.measure and rejoin_hyphenated take &dyn SplitMeasure (nerdy-pro#153).

Verified on this exact tree: cargo fmt, clippy and doc clean; cargo test
2977 passed / 0 failed (skipping the capi library freshness guards);
--no-default-features and --features capi build; the twelve-document
harness passes 12/12.

This branch has not been deployed

No deployments
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.

Decode WMF and SVG images instead of detecting and skipping them

1 participant