Skip to content

PDF export: vector slides with real text; effects rasterize per element - #245

Open
pierrevbc wants to merge 4 commits into
storytold:mainfrom
pierrevbc:pdf-vector
Open

pierrevbc wants to merge 4 commits into
storytold:mainfrom
pierrevbc:pdf-vector

Conversation

@pierrevbc

@pierrevbc pierrevbc commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Problem

docs/gaps.md lists "PDF export rasterizes slide artwork" as a beta blocker: each slide is one
raster image at print resolution under an invisible text layer. Files are large (0.1 to 2 MB a
slide at the default 200 dpi) and blurry when zoomed or printed.

What changes

Four commits, rebased on main at 9859da3 (after the shared PDF writer, #708): the export, the
progress docs, tests for the drawing main gained since this PR was opened (pattern fills,
rectangle and shape path gradients, fill overlays, compound outlines, text shadows and glows,
gradient text, colour emoji, WMF/EMF pictures, WordArt warps), and tests for the newer bevels,
lit faces, group shadows, circle gradients, picture tiles and upright vertical text.

Slides are drawn by the same renderer through a vector canvas, so shapes, outlines (width, dashes,
caps, joins, arrowheads), solid and linear/radial gradient fills, transparency, rotation, flips,
groups, tables and charts become PDF paths and paints, and text is real text in the embedded font
subsets (searchable, selectable, mapped back to Unicode cluster by cluster). JPEG and PNG pictures
are embedded as they are (JPEG stays DCT), others as lossless pixels; crops and picture
transparency are kept. Backgrounds (solid, gradient, picture) are vector too.

What a PDF can't express is rasterized locally, at dpi, in place, never dropped: a shape with an
outer or inner shadow, glow, soft edges, reflection or a fill overlay (its geometry and effects;
its text stays vector), pattern fills, rectangle and shape path gradients, text shadows and glows
(over the glyphs' area; the text itself stays real text), colour emoji (over the glyph's em box;
the letters around stay text) and WMF/EMF pictures (decoded to pixels, as on screen, at print
resolution). Gradient text is drawn glyph by glyph as vector paths; compound outlines, arrowheads
and tables with file-defined styles are vector. Pictures the renderer can't decode show the
placeholder they show on screen. Glyphs from a variable font's non-default named instance are
drawn as outlines over invisible real text, since the embedded subset is the default instance.

vector: false (file.export, MCP export) keeps the raster mode, byte for byte. Links,
bookmarks, notes pages, handouts and hidden-slide handling are unchanged.

By crate:

  • render (canvas.rs, lib.rs, paint.rs, table.rs, chart.rs, warp.rs,
    images.rs, colr.rs): a Canvas trait for every drawing call; vello_cpu's RenderContext
    implements it, so on-screen output is unchanged. draw_slide draws a slide on any canvas
    (shapes, backgrounds, tables, charts, text, warps). canvas::pixels runs drawing that only
    exists in pixels directly on a pixel canvas and rasterizes it locally on a vector one. Shapes
    with effects or a fill overlay are rasterized whole over the area extent::spill computes,
    with the view and a useBgFill background shifted so strokes stay aligned. paint::gradient
    returns a Paint (gradient text no longer needs a paint transform, which vector canvases
    lack); colr::has_color tells colour glyphs apart. Pictures hand the canvas the original file
    when it is an unchanged JPEG or PNG.
  • text: TextLayout::texts, each paragraph's characters as laid out (fields filled in), for
    glyph-to-text mapping.
  • pdf: krilla canvas (vector.rs, on craft_pdf::write::backend) with font and image
    caches; PdfOptions::vector (default true); dpi now also sets the resolution of local
    rasters. It keeps Use the shared PDF writer and report font embedding failures #708's contract: the export runs under write::guard, fonts go through
    write::font, and a font that can't be embedded fails the export with its error on vector
    slides too (never dropped or substituted text). New dependency on deckcraft-color (workspace
    crate, same layer rules); cargo xtask pdf-deps passes.
  • render, drawing added on main since: chartex charts (waterfall, funnel, treemap, sunburst,
    box), radar and stock marks, chart trendlines, 3-D extrusion sides and contours take
    &mut dyn Canvas and stay vector; bevels and lit faces, path gradients and patterns go through
    canvas::pixels; the table painter's off-screen text skip uses the slide box on a vector
    canvas; a cropped picture is decoded for its whole size (Boolean intersect blurs raster image output #762) on both routes.
  • engine, mcp: vector export parameter.
  • Progress docs: the gap "PDF export rasterizes slide artwork" is removed from docs/gaps.md
    (top 14 now); "Export to PDF" goes from P to D in docs/parity-checklist.md (summary regenerated
    by cargo xtask parity); docs/file-format-parity.md, docs/architecture.md,
    docs/roadmap.md (share row and beta item 5 now cover printing only) and the ROADMAP.md
    progress log are updated.

How it was tested

cargo xtask ci passes on this branch, rebased on main at 9859da3 (all 7 steps: fmt, pdf-deps,
clippy -D warnings, 1,690 tests passed and 6 ignored, assets, layers, wasm32 check). On Windows
the deckcraft-ui-egui lib tests sometimes crash with an access violation when run in parallel,
on main as well; the run above used RUST_TEST_THREADS=1.

New tests, all on decks generated in code:

  • crates/pdf: vector_slides_are_paths_and_text_without_a_page_image,
    vector_text_is_extractable, vector_pdf_is_much_smaller_than_raster,
    a_shadow_rasterizes_that_shape_only, pattern_fills_rasterize_their_area_only,
    jpeg_pictures_are_embedded_as_they_are (byte for byte), links_and_bookmarks_survive_vector_export
    (slides and handouts).
  • crates/render: draw_slide_on_a_pixel_canvas_matches_render_slide (zero pixel difference), and
    vector_canvases_get_paths_and_text_and_effects_as_local_pixels, with a recording canvas that
    checks what reaches a vector canvas. Third commit, same recording canvas:
    vector_canvases_get_pixel_only_fills_as_local_pixels (pattern, rectangle path gradient, fill
    overlay over that element only; a triple compound outline as strokes),
    vector_canvases_keep_text_and_rasterize_its_effects (text shadow and glow as pixels, glyph
    runs kept; gradient text as paths), vector_canvases_get_metafile_pictures_as_pixels (a WMF
    built in the test), vector_canvases_get_colour_emoji_as_pixels (when a colour font is
    installed).
  • crates/pdf (third commit): pixel_only_drawing_exports_as_local_images, a vector PDF with a
    pattern, a path gradient, shadowed text with an emoji and a WMF picture: no panic, local images
    only, extractable text.

Checked by hand with the built CLI on this branch: the sample deck exports to 0.14 MB vector
(1.01 MB with vector: false); its 8 pages render in poppler and pdftotext gives the slide text.
A deck generated with deckcraft-cli run (gradient, shadow and glow, pattern, bevel, chart,
table) gives 2 pages, extractable text including the chart labels, and 3 local images (the
shadowed shape, the pattern, the bevel). Earlier, on an older main: a 21-slide public deck from
10.4 MB to 0.73 MB.

  • crates/pdf (Use the shared PDF writer and report font embedding failures #708 contract): unembeddable_font_returns_error_instead_of_a_pdf_missing_text
    now covers vector and raster slides; text_layer_links_and_bookmarks_survive_serialization
    covers both modes.
  • crates/render/src/tests_vector.rs (fourth commit): replays the vector route into pixels and
    compares it with the pixel route (off-centre, tiled and rotWithShape=0 gradients, flipped and
    aligned tiles, bevel and lit face, group shadow, grpFill under a shadow, gradient lines and
    heads), and counts the local rasters.

Known limits

  • Shadows, glow, soft edges, reflection, fill overlays, pattern fills, rectangle and shape path
    gradients, text shadows and glows, colour emoji and WMF/EMF pictures are still pixels (per
    element, at dpi).
  • WordArt (warped), gradient and emoji text isn't selectable text in the PDF (paths or pixels).
  • The text of a shape with effects stays vector, drawn over the shape's raster.
  • Printing is still missing; the share row of docs/roadmap.md now covers only that.

Related PRs

Each slide was one raster image under an invisible text layer: large files (0.1 to 2 MB a slide at
the default 200 dpi), blurry when zoomed or printed. Slides are now drawn by the same renderer
through a vector canvas, so shapes, outlines (width, dashes, caps, joins, arrowheads), solid and
linear/radial gradient fills, transparency, rotation, flips, groups, tables and charts become PDF
paths and paints, and text is real text in the embedded font subsets (searchable, selectable,
mapped back to Unicode cluster by cluster). JPEG and PNG pictures are embedded as they are (JPEG
stays DCT), others as lossless pixels; crops and picture transparency are kept. Backgrounds
(solid, gradient, picture) are vector too.

What a PDF can't express is rasterized locally, at `dpi`, in place: a shape with an outer or
inner shadow, glow, soft edges or reflection (its geometry and effects; its text stays vector) and
pattern fills. Undecodable pictures (EMF/WMF, SVG) show the placeholder they show on screen.
Glyphs from a variable font's non-default named instance are drawn as outlines over invisible
real text, since the embedded subset is the default instance.

The sample deck goes from 1.04 MB to 0.18 MB, a 21-slide public talk from 10.4 MB to 0.73 MB;
`vector: false` (file.export, MCP export) keeps the raster mode, byte for byte. Links, bookmarks,
notes pages, handouts and hidden-slide handling are unchanged.

- render: `Canvas` trait (canvas.rs) for every drawing call; `vello_cpu`'s RenderContext
  implements it, so on-screen output is unchanged. `draw_slide` draws a slide on any canvas.
  Effects need the pixel context; vector canvases get those shapes through `rasterize`, a
  transparent pixmap covering the shape and its effects. Pictures hand the canvas the original
  file when it is an unchanged JPEG or PNG.
- text: `TextLayout::texts`, each paragraph's characters as laid out (fields filled in), for
  glyph-to-text mapping.
- pdf: krilla canvas (vector.rs) with font and image caches; `PdfOptions::vector` (default
  true); `dpi` now also sets the resolution of local rasters.
- engine, mcp: `vector` export parameter.
- tests: decks generated in code; paths and text without a page image, extractable text, a
  shadow rasterizes only its shape, pattern fills rasterize their area, JPEG embedded byte for
  byte, links and bookmarks in slides and handouts, size against the raster mode; a recording
  canvas checks what reaches a vector canvas; drawing on a pixel canvas matches render_slide.
- gaps.md: "PDF export rasterizes slide artwork" removed (top 14 now).
- file-format-parity.md, parity-checklist.md (Export to PDF: D, summary regenerated),
  architecture.md, roadmap.md (share row and beta item 5 now printing only), ROADMAP.md log.
The vector PDF canvas was rebased on renderer code that draws some
things only on the pixel context: pattern tiles, rectangle and outline
path gradients, blended fill overlays, text shadows and glows, colour
emoji (COLR glyphs) and WMF/EMF pictures. The rebase routes them
through the per-element raster fallback (canvas::pixels, text effects
and colour glyphs rasterized over their area) and draws gradient text
glyph by glyph as vector paths; these tests pin it down.

- render: a recording canvas checks each case: pixels over that element
  only, glyph runs kept for the text around effects and emoji, compound
  outlines as strokes, metafiles as decoded pixels.
- pdf: a vector PDF with a pattern, a path gradient, shadowed text with
  an emoji and a WMF picture exports without panicking, with local
  images and extractable text.
- file-format-parity.md (PDF row) and architecture.md list what
  rasterizes.
…nd vertical text

The renderer now lights 3-D bevels and faces, casts a group's shadow from
its children, tiles pictures with flips, alignment and their recorded
resolution, draws circle gradients from an off-centre focus to the circle
around their tile, and stands East Asian vertical text upright. On a
vector canvas: bevels, lit faces and group shadows are local rasters over
their area (never dropped); tiles are one embedded picture per tile, the
mirrored ones flipped; circle gradients are two-circle PDF gradients;
upright glyphs are outlines over the same text drawn invisibly (still
searchable and copyable); children filled with the group fill keep it
laid out across the group inside a local raster.

- render: tests_vector.rs replays the vector route into pixels and
  compares it with the pixel route on those cases (off-centre, tiled and
  rotWithShape=0 gradients, flipped and aligned tiles, bevel and lit face,
  group shadow, grpFill under a shadow, gradient lines and heads), and
  counts the local rasters.
- pdf: upright vertical text exports as invisible text under outlines;
  horizontal text stays plain text.
anishguduru pushed a commit to anishguduru/deckcraft that referenced this pull request Oct 16, 2026
aiongg added a commit to aiongg/deckcraft that referenced this pull request Oct 23, 2026
aiongg added a commit that referenced this pull request Oct 23, 2026
* Media: use craft-media for bitstream, isobmff and matroska

deckcraft-bitstream, deckcraft-isobmff and deckcraft-matroska re-export
craft_media::{bitstream, isobmff, matroska} from craft-libs. The packages
and their public paths stay, so the codec crates build unchanged.
deckcraft-bitstream keeps the decoder memory budget as its own module
(budget). deckcraft-media names the new CodecConfig::Apv entry.

* Media: pin craft-media to storytold/craft-libs at f97c84d2 (#245)

* Media: pin craft-media to storytold/craft-libs at f0ad6c1e (#246)
@Harrison-Booth

Copy link
Copy Markdown

Thanks for this. Exporting vector slides with real text, instead of one raster image per slide, still looks valuable for file size and sharpness. I checked just now: the PR is open, GitHub reports it conflicts with main, and no one has commented on it. Do you plan to bring it up to date with main? If you would rather not, I can open a PR that credits you, keeps your commits and shows a git range-diff against this one, so you'll still get credit and have visible work.

@pierrevbc

Copy link
Copy Markdown
Contributor Author

Thanks for picking this up. I don't plan to bring it up to date myself, so please go ahead and open the new PR with my commits and the credit. Feel free to close this one when yours is up.

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.

2 participants