Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 892f0f73d6 Render tests: the arch top bound allows Linux's fallback font
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 19:06:31 -04:00
Brandon ThomasandClaude Opus 5.5 55a53f8ecb Fonts: missing fonts draw as PowerPoint draws them, and Windows family names resolve (#322)
* Text: a missing font is drawn in Calibri, as PowerPoint does

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fonts: Windows family names find their faces; PowerPoint's twins for missing fonts

"Segoe UI Semibold", "Arial Black" and the like are the faces' Windows (GDI) family
names; a family's installed files now load together; Liberation Sans and Serif draw as
Arial and Times New Roman when missing, as PowerPoint draws them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fonts: Office cloud fonts (Aptos…) keep their open substitute

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 19:05:48 -04:00
Brandon ThomasandClaude Opus 5.5 f8d54d7af4 Pictures: WMF and EMF draw, with the metafile reader ported from WordCraft (#317)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:27:19 -04:00
Brandon ThomasandClaude Opus 5.5 a293b68258 Shapes: add 38 missing preset geometries (#315)
lineInv, nonIsoscelesTrapezoid, pieWedge, the left-right / up-down / quad
arrow callouts, circularArrow / leftCircularArrow / leftRightCircularArrow,
the four curved arrows, swooshArrow, callout1-3, accentCallout1-3,
borderCallout2-3, accentBorderCallout1-3, ellipseRibbon / ellipseRibbon2,
leftRightRibbon, flowChartOfflineStorage, gear6 / gear9, funnel, the corner
tabs (corner, square, plaque) and chartX / chartStar / chartPlus no longer
fall back to plain rectangles.

Each outline is our own construction in crates/geom/src/preset/extra.rs,
fitted black-box against PowerPoint renders of synthetic probe decks at
default, changed and extreme adjust values and several aspect ratios
(outline within ~1pt on 300-480pt shapes). Tests cover catalog presence,
hostile sizes/adjusts and measured outline points.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:15:54 -04:00
Brandon ThomasandClaude Opus 5.5 dedb20890b Text: WordArt transforms (prstTxWarp) draw, and their adjusts round-trip (#312)
* Text warps: draw WordArt prstTxWarp presets, round-trip their adjusts

All 40 DrawingML text-warp presets now render instead of drawing flat text.
A warp is a list of bands over the shape's text rectangle (insets ignored):
fill bands stretch the ink of their lines between a top and a bottom guide
(positions by arc length, one horizontal scale for the whole text), follow
bands (arch, circle, button) set lines at natural size along an ellipse arc,
lines stacking outwards on parallel curves, with no wrapping. Lines are
shared over multi-band presets in order (ceil(n/k) per band).

The guide curves were fitted to PowerPoint renders of synthetic decks of
"I" bars (black-box observation, defaults and changed adjusts, 1-3 lines);
most presets match within about a point on a 420 x 200 pt box.

- geom: warp module (guides, presets, band mapping) with unit tests
- render: glyph outlines and underlines flattened, subdivided and mapped
- text: follow warps lay out without wrapping
- model/pptx: BodyProps.warp_adj read from and written to prstTxWarp/avLst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Text warps: bands with fewer lines keep the fuller band's text height

Multi-band fill warps (Deflate-Inflate, Button Pour) map a box as tall as
the tallest band's ink onto every band, centred on the band's own ink, as
PowerPoint does: a band holding one line next to a band of two keeps the
line at the same size instead of stretching it over the whole band.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:12:58 -04:00
Brandon ThomasandClaude Opus 5.5 5835f0a106 Text: no Latin ligatures, and kerning only from the run's kern size (#309)
PowerPoint keeps fi, ffi and Calibri's ti apart and kerns a run only when
a:rPr/@kern is set and the size reaches it; DeckCraft shaped with the font's
defaults (ligatures and kerning always on), so Calibri text read differently
and kern="0" text came out narrower.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:11:10 -04:00
Brandon ThomasandClaude Opus 5.5 a520dd4d1d Render: shadows, inner shadows and glows spread like PowerPoint's (#305)
* Render: shadows, inner shadows and glows spread like PowerPoint's

Measured on PowerPoint's export of probe shapes:
- outer and inner shadows are blurred by a third of the blur radius (was
  half), so their profile matches pixel for pixel;
- an inner shadow falls inside along the edges its direction points to (45
  degrees: bottom and right); DeckCraft put it on the opposite edges;
- a glow of radius r is half strength about r/2 out and gone by r (was a
  wider, softer halo).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Render tests: probe the outer shadow where the narrower blur still reaches

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:09:46 -04:00
Brandon ThomasandClaude Opus 5.5 fd2f8146b4 Bullets: an installed symbol font draws its own glyph (#293)
Bullets in Wingdings, Wingdings 2/3, Symbol and other symbol fonts were
always swapped for a look-alike from one Wingdings table, so a Wingdings 2
star (0xEA) became a round dot. When the deck's symbol font is installed,
its glyph is drawn (symbol fonts map character codes at U+F000 + code), as
PowerPoint does; otherwise Wingdings 2 and 3 codes get their own
look-alikes.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:06:30 -04:00
Brandon ThomasandClaude Opus 5.5 1679836540 Tables: the table's own fill (a:tblPr fill) is drawn behind its cells (#294)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:05:12 -04:00
Brandon ThomasandClaude Opus 5.5 d8407c11f9 Tables: styles the file defines are read, drawn and kept (#292)
* Tables: styles the file defines are read, drawn and kept

PowerPoint writes the definition of every table style a deck uses into
ppt/tableStyles.xml. DeckCraft mapped a handful of GUIDs to its own looks
and drew any other style as Medium Style 2, so themed and light styles came
out in the wrong colours, and dropped the definitions on save.

The reader now keeps each definition (whole table, bands, first/last
rows and columns, corner cells, table background; fills, theme fill and
line references, borders, text colour and bold) on the presentation, and
tables using one keep its GUID. The renderer applies the parts that cover
each cell in the spec's order, a part's edge lines on its region's edges
and its inside lines within it, and paints fills as real fills (gradients
and see-through colours). The writer puts the definitions back as read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tables: DeckCraft's own table styles still map back to their ids

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fix a brace lost in the rebase

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:03:19 -04:00
Brandon ThomasandClaude Opus 5.5 880b974cff Render: soft edges fade to the shape's outline (#289)
The soft-edge mask was the bounding box shrunk and blurred, so ovals and
other non-rectangular shapes kept hard edges where the box was wider, and
a rectangle's fade began outside it. The mask is now the outline itself,
shrunk by 0.85 r (a stroke punched out of the fill) and blurred by r/3:
transparent at the outline, half opaque 0.85 r inside, as measured on
PowerPoint's export of a 12 pt soft edge.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:00:38 -04:00
Brandon ThomasandClaude Opus 5.5 b3e33928e2 Render: rectangle and outline path gradients (#288)
Path gradients that follow a rectangle (path="rect") or the shape's own
outline (path="shape") were drawn as circles. They're now rasterised into
a small image of the bounds: rect takes the larger of the horizontal and
vertical shares of the distance from the focus to the edge (X-shaped
contours), shape the share of the way from the focus to the outline along
the same ray (an ellipse gives ellipses, a triangle triangles), as
measured on PowerPoint's export. The two path probe decks now match it to
a mean pixel difference of 1.7 and 0.6 (from 15.6 and 22).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:59:32 -04:00
Brandon ThomasandClaude Opus 5.5 d0a3d924bf Render: compound outlines (double, thick-thin, thin-thick, triple) (#287)
a:ln/@cmpd was read but every outline was drawn as one band. Each band is
now stroked along the path moved to its place across the width, in the
shares PowerPoint uses (measured on its export of 24 pt lines: double
1/3-1/3-1/3, thin-thick 1/5-1/5-3/5, triple 1/6-1/6-1/3-1/6-1/6), the
first band on the left of travel (the outside of a shape). Line corners
meet at their miter points; curves are offset with kurbo.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:58:10 -04:00
Brandon ThomasandClaude Opus 5.5 81b6a9df39 Render: pattern fills draw PowerPoint's preset tiles (#285)
Pattern fills were approximated (dots, bars or a grid per family, on an
8-point cell), so pct30 looked like sparse dots and wave like a dot grid.
Every one of the 54 DrawingML presets now draws its 8x8 tile, each cell one
96-dpi pixel (0.75 pt), laid out from the slide's origin. The tiles were
read from PowerPoint's own export of each preset at two output pixels per
cell; the pattern probe deck now matches it to a mean pixel difference of
0.2 (from 62).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:56:30 -04:00
Brandon ThomasandClaude Opus 5.5 d7fbbaeab1 Bullets: a symbol the text font lacks falls back to a font that has it (#284)
Circled-number lists (circleNumDbPlain) and character bullets whose glyph
isn't in the paragraph's font were drawn as missing-glyph boxes (numbers)
or always from Inter (characters). Both now go through the usual font
fallback, as PowerPoint draws them.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:54:25 -04:00
Brandon ThomasandClaude Opus 5.5 2bf62c97e2 Text: shadows and glows are drawn (#283)
Run shadows and glows (a:rPr/a:effectLst outerShdw, glow) were read but
never drawn. The text layout now carries them on each glyph run (colours
resolved, sizes scaled with autofit) and the renderer draws every run's
shadow, then every glow, blurred, under the text. A text glow is solid to
about a third of its radius past the letters and fades out by two thirds,
as measured on PowerPoint's export. needs_filters also looks at text
effects (runs, list styles, master text styles), so they render with the
blur filter.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:53:17 -04:00
Brandon ThomasandClaude Opus 5.5 1bb86db54a Text: gradient fills draw, starting over on every line (#281)
A gradient on text was read but drawn in its first colour. Each line is now
filled with the whole gradient, spanning that line's text from its top to
its bottom and its first to its last character, as PowerPoint does. The
gradient builder moves out of fill_path into paint::gradient so fills and
text share it (and PowerPoint's two-colour ramp). Gradient colours are
resolved when the text style is made.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:51:20 -04:00
Brandon ThomasandClaude Opus 5.5 ba56742fa9 Emoji: drawn in colour, with families, skin tones and flags joined (#278)
* Emoji: colour glyphs, and emoji take the colour emoji font

Emoji were drawn as black outlines from a symbol font, with ZWJ sequences
(families, flags) falling apart into their parts. Now:

- an emoji cluster (emoji presentation, U+FE0F, ZWJ between pictographs,
  skin tones, regional indicators, keycaps) takes an installed colour font
  (COLR, CBDT, sbix or SVG tables) before the document's fonts, so its
  sequences ligate as in PowerPoint; U+FE0E keeps a plain face;
- COLR v0 and v1 glyphs are painted in their CPAL colours (solid fills,
  linear, radial and sweep gradients, transforms, composite modes), with
  palette index 0xFFFF in the text colour.

A tiny COLRv0 test font (two squares, CC0, regenerated by
make_colr_font.py) backs the render test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Render: lock skrifa for colour glyphs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:48:51 -04:00
Brandon ThomasandClaude Opus 5.5 8b160d5b03 Text: wrap URLs, Korean and CJK punctuation where PowerPoint does (#276)
* Text: wrap lines where PowerPoint does

- Without latinLnBrk (PowerPoint's default) a word doesn't break after '/'
  or '\': URLs and paths fill the line and break at its edge.
- Korean wraps between words, not between syllables.
- With hangingPunct, closing East Asian punctuation hangs past the end of
  the line instead of dragging the previous character down.

ParaProps gains latin_line_break and hanging_punct, read and written as
latinLnBrk and hangingPunct.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Text: simpler break guard (clippy)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 17:47:12 -04:00
62 changed files with 8296 additions and 130 deletions
+4
View File
@@ -27,6 +27,10 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene
| `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests |
| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests |
| `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures |
| `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests |
| `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
| `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests |
| `crates/render/tests/fixtures/make_colr_font.py` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the COLR test font |
## Bundled through dependencies
Generated
+10
View File
@@ -1046,6 +1046,14 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-metafile"
version = "0.6.1"
dependencies = [
"kurbo",
"thiserror 2.0.21",
]
[[package]]
name = "deckcraft-model"
version = "0.6.1"
@@ -1103,11 +1111,13 @@ dependencies = [
"deckcraft-color",
"deckcraft-fonts",
"deckcraft-geom",
"deckcraft-metafile",
"deckcraft-model",
"deckcraft-text",
"image",
"kurbo",
"log",
"skrifa 0.47.0",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
]
+1
View File
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/deckcraft"
# internal
deckcraft-geom = { path = "crates/geom" }
deckcraft-color = { path = "crates/color" }
deckcraft-metafile = { path = "crates/metafile" }
deckcraft-model = { path = "crates/model" }
deckcraft-fonts = { path = "crates/fonts" }
deckcraft-text = { path = "crates/text" }
+1
View File
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
+35
View File
@@ -24,6 +24,22 @@ fn ignorable(c: char) -> bool {
|| matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2069 | 0xFE00..=0xFE0F | 0xFEFF | 0xE0100..=0xE01EF)
}
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation, or
/// any sequence with an emoji presentation selector (U+FE0F), a zero-width joiner between
/// pictographs, a skin tone modifier, regional indicators (flags) or a keycap?
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
if cluster.contains(&'\u{FE0E}') {
return false;
}
let pictographic = |c: char| matches!(c as u32, 0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF | 0x2190..=0x21FF | 0x2300..=0x23FF);
let default_emoji =
|c: char| matches!(c as u32, 0x1F300..=0x1F64F | 0x1F680..=0x1F6FF | 0x1F900..=0x1F9FF | 0x1FA70..=0x1FAFF | 0x1F1E6..=0x1F1FF);
cluster.iter().any(|&c| default_emoji(c))
|| (cluster.contains(&'\u{FE0F}') && cluster.iter().any(|&c| pictographic(c) || c.is_ascii_digit() || c == '#' || c == '*'))
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|| cluster.contains(&'\u{20E3}')
}
/// Does `face` draw every character of the cluster?
pub fn covers_cluster(face: &FontFace, cluster: &[char]) -> bool {
cluster.iter().all(|c| ignorable(*c) || face.covers(*c))
@@ -55,9 +71,28 @@ impl FontDb {
/// bundled face for its script, else an installed face for the script, else any loaded
/// face that draws it, else `chain[0]` (or the default face if `chain` is empty).
pub fn cascade(&self, cluster: &[char], chain: &[Arc<FontFace>], style: &str) -> Arc<FontFace> {
// Emoji take a colour face (with its ZWJ sequences, skin tones and flags) before the
// document's fonts, as PowerPoint draws them, even when a text font has a plain glyph.
if is_emoji_cluster(cluster) {
if let Some(f) = chain.iter().find(|f| f.is_color() && covers_cluster(f, cluster)) {
return f.clone();
}
if let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
&& let Some(f) = self.color_face_for(key, cluster)
{
return f;
}
}
if let Some(f) = chain.iter().find(|f| covers_cluster(f, cluster)) {
return f.clone();
}
// Text presentation (U+FE0E): a plain face, not a colour emoji one.
if cluster.contains(&'\u{FE0E}') {
let faces = self.read_faces();
if let Some(f) = faces.iter().find(|f| !f.is_color() && covers_cluster(f, cluster)) {
return f.clone();
}
}
let first = || chain.first().cloned().unwrap_or_else(|| self.face(crate::DEFAULT_FAMILY, style));
let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c)) else { return first() };
let script = cluster.iter().find_map(|c| script_of(*c));
+194 -11
View File
@@ -52,6 +52,53 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
}
}
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
/// them breaks into the same lines.
fn has_metric_twin(family: &str) -> bool {
matches!(
family.to_ascii_lowercase().as_str(),
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
)
}
/// The installed family PowerPoint draws a missing `family` with instead of Calibri: Liberation
/// Sans and Serif as Arial and Times New Roman (observed in PowerPoint; Liberation Mono, Carlito,
/// Caladea, Arimo and Tinos are not mapped and draw in Calibri), and Windows' standard font
/// substitutes (Arial CE, Times New Roman CYR…).
fn office_twin(family: &str) -> Option<&'static str> {
let f = family.to_ascii_lowercase();
Some(match f.as_str() {
"liberation sans" | "helvetica" | "helv" => "Arial",
"liberation serif" | "times" | "tms rmn" => "Times New Roman",
_ => {
let base = f
.strip_suffix(" baltic")
.or(f.strip_suffix(" ce"))
.or(f.strip_suffix(" cyr"))
.or(f.strip_suffix(" greek"))
.or(f.strip_suffix(" tur"))?;
match base {
"arial" => "Arial",
"courier new" => "Courier New",
"times new roman" => "Times New Roman",
_ => return None,
}
}
})
}
/// Office's downloadable (cloud) families: PowerPoint fetches them when a document uses them, so
/// they draw as themselves there, not as Calibri. Without them DeckCraft keeps their closest open
/// substitute ([`substitutes`]; Aptos as Inter).
fn is_office_cloud_font(family: &str) -> bool {
let f = family.to_ascii_lowercase();
f.starts_with("aptos") || ["bierstadt", "grandview", "seaford", "skeena", "tenorite"].iter().any(|c| f.starts_with(c))
}
/// The family PowerPoint draws a document's Latin text in when the font it names isn't installed,
/// whatever the theme, `panose` or `pitchFamily` say.
pub const MISSING_FONT_FAMILY: &str = "Calibri";
#[derive(Clone)]
enum FontBytes {
Static(&'static [u8]),
@@ -65,6 +112,10 @@ pub struct FontFace {
pub family: String,
/// Typographic style name (e.g. "Semibold", "Italic").
pub style: String,
/// The Windows (GDI) family and style names (`name` IDs 1 and 2) when the family differs from
/// the typographic one: "Segoe UI Semibold" / "Regular" for Segoe UI Semibold. Documents name
/// fonts this way.
pub legacy: Option<(String, String)>,
/// usWeightClass-style weight (400 = regular).
pub weight: f32,
pub italic: bool,
@@ -91,6 +142,8 @@ pub struct FontFace {
pub instance: Option<harfrust::ShaperInstance>,
/// Basic Multilingual Plane coverage bitset, built on first use.
bmp: std::sync::OnceLock<Box<[u64]>>,
/// Colour glyph tables present (`is_color`), looked up on first use.
color: std::sync::OnceLock<bool>,
}
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
@@ -158,6 +211,11 @@ impl FontFace {
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
skrifa::FontRef::from_index(self.data(), self.index).ok()
}
/// Does the face draw colour glyphs (a `COLR`, `CBDT`, `sbix` or `SVG ` table, as colour emoji
/// fonts have)?
pub fn is_color(&self) -> bool {
*self.color.get_or_init(|| has_color_tables(self.data(), self.index))
}
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
harfrust::FontRef::from_index(self.data(), self.index).ok()
}
@@ -246,6 +304,11 @@ pub struct FontDb {
/// scan, so what they find doesn't depend on what ran before them.
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock<()>,
/// Families (lower case) whose cataloged files have all been loaded: a family's files load
/// together, so a lookup never settles for the one face another name loaded ("Segoe UI
/// Semibold" loads a face of the family "Segoe UI").
#[cfg(not(target_arch = "wasm32"))]
loaded_families: Mutex<std::collections::HashSet<String>>,
/// System fallback state: enabled, and characters no system font covers.
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex<SysFallback>,
@@ -258,6 +321,13 @@ struct SysFallback {
misses: std::collections::HashSet<char>,
}
/// Does face `index` of `data` carry colour glyph tables?
fn has_color_tables(data: &[u8], index: u32) -> bool {
use skrifa::raw::types::Tag;
let Ok(f) = skrifa::FontRef::from_index(data, index) else { return false };
[b"COLR", b"CBDT", b"sbix", b"SVG "].iter().any(|t| f.table_data(Tag::new(t)).is_some())
}
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
#[cfg(not(target_arch = "wasm32"))]
const SYSTEM_FALLBACKS: &[&str] = &[
@@ -302,6 +372,16 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
Some((family, style))
}
/// A face's Windows (GDI) family and style (`name` IDs 1 and 2) when that family differs from the
/// typographic `family`.
fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String)> {
let legacy = name(f, &[StringId::FAMILY_NAME])?;
if legacy.eq_ignore_ascii_case(family) {
return None;
}
Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into())))
}
/// (family, style) of every face in the font file at `path`, reading only its table directories
/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A
/// variable font is cataloged under its default style; its named instances appear once it loads.
@@ -348,8 +428,13 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> {
font.extend_from_slice(&u32::to_be_bytes(v));
}
font.extend_from_slice(&table);
face_names(&skrifa::FontRef::new(&font).ok()?)
let f = skrifa::FontRef::new(&font).ok()?;
let (family, style) = face_names(&f)?;
// Cataloged under its Windows family too, so "Segoe UI Semibold" finds the file.
let legacy = legacy_names(&f, &family);
Some(std::iter::once((family, style)).chain(legacy))
})
.flatten()
.collect()
}
@@ -477,10 +562,12 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
.map(|v| v >= 0.5)
.or(axis(b"slnt").map(|v| v.abs() > 0.1))
.unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style));
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None };
Some(FontFace {
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
style,
legacy,
weight,
italic,
upem: m.units_per_em.max(1) as f64,
@@ -496,6 +583,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
bytes,
index,
bmp: std::sync::OnceLock::new(),
color: std::sync::OnceLock::new(),
})
}
@@ -556,6 +644,8 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock::new(),
#[cfg(not(target_arch = "wasm32"))]
loaded_families: Mutex::new(std::collections::HashSet::new()),
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
}
}
@@ -661,6 +751,8 @@ impl FontDb {
/// Returns the number of faces cataloged.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_system_fonts(&self) -> usize {
// Families load again from the new catalog when next looked up.
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
// The first scan, or a rescan once it is done (never both at once).
let mut first = None;
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
@@ -741,17 +833,35 @@ impl FontDb {
.unwrap_or_else(last_resort_face)
}
/// The face a document's text in `family` is drawn with, as PowerPoint draws it: the family
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
return self.face(family, style);
}
if let Some(twin) = office_twin(family) {
return self.face(twin, style);
}
self.face(MISSING_FONT_FAMILY, style)
}
fn try_family(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
if let Some(f) = self.find(family, style) {
return Some(f);
}
// Every installed file of the family first, so the best style is among the candidates. The
// lock is held while the files load, so another thread looking up the family waits for
// them rather than finding none and falling back to another family.
#[cfg(not(target_arch = "wasm32"))]
if self.load_cataloged(family)
&& let Some(f) = self.find(family, style)
{
return Some(f);
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
let key = family.to_lowercase();
if !loaded.contains(&key) {
self.load_cataloged(family);
loaded.insert(key);
}
}
None
self.find(family, style)
}
/// Is `family` available (loaded, or installed on the system)?
@@ -767,7 +877,9 @@ impl FontDb {
}
pub(crate) fn is_loaded(&self, family: &str) -> bool {
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
self.read_faces()
.iter()
.any(|f| f.family.eq_ignore_ascii_case(family) || f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family)))
}
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
@@ -822,11 +934,27 @@ impl FontDb {
return self.instance(family, style);
}
let faces = self.read_faces();
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
let mut cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
let ns = norm(style);
if cands.is_empty() {
// A Windows family name ("Segoe UI Semibold", "Arial Black"): its faces, matched by
// their Windows style ("Regular", "Bold"…).
cands = faces.iter().filter(|f| f.legacy.as_ref().is_some_and(|(lf, _)| lf.eq_ignore_ascii_case(family))).collect();
if let Some(f) = cands.iter().find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == ns)) {
return Some((*f).clone());
}
// Bold of a family that is itself a weight (Semibold, Light): the face, made bolder
// by the caller, as Windows does, rather than a face of another weight.
if let Some(f) = cands
.iter()
.find(|f| f.legacy.as_ref().is_some_and(|(_, ls)| norm(ls) == norm(if style_italic(style) { "Italic" } else { "Regular" })))
{
return Some((*f).clone());
}
}
if cands.is_empty() {
return None;
}
let ns = norm(style);
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
return Some((*f).clone());
}
@@ -902,6 +1030,61 @@ impl FontDb {
false
}
/// A loaded colour face drawing all of `cluster`, loading an installed colour font that covers
/// `key` if none is loaded yet. Misses are remembered.
pub(crate) fn color_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
let pick = |db: &FontDb| {
let faces = db.read_faces();
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| crate::covers_cluster(f, cluster) && f.is_color()).collect();
v.sort_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32));
v.first().map(|f| (*f).clone())
};
if let Some(f) = pick(self) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.system_color_fallback(key) {
return pick(self);
}
let _ = key;
None
}
/// Load an installed colour font covering `c`. True if one was loaded.
#[cfg(not(target_arch = "wasm32"))]
fn system_color_fallback(&self, c: char) -> bool {
static MISSES: Mutex<Option<std::collections::HashSet<char>>> = Mutex::new(None);
if c.is_control() || c.is_whitespace() {
return false;
}
{
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
if !sys.enabled {
return false;
}
}
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
return false;
}
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
for p in paths {
if std::fs::metadata(&p).map(|m| m.len() > 64 << 20).unwrap_or(true) {
continue;
}
let Ok(data) = std::fs::read(&p) else { continue };
let hit = enumerate_faces(&data).iter().any(|(i, _, _, _)| {
has_color_tables(&data, *i) && skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())
});
if hit && self.add_font(data) > 0 {
return true;
}
}
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
false
}
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
+82 -2
View File
@@ -13,8 +13,10 @@ mod fontdb;
pub mod script;
mod shape;
pub use fallback::{bundled_families, covers_cluster, ligature_carets};
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs};
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
pub use fontdb::{
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
};
pub use harfrust::Feature;
use harfrust::Tag;
pub use kurbo::BezPath;
@@ -136,6 +138,25 @@ mod tests {
FontDb::global().face(DEFAULT_FAMILY, "Regular")
}
#[test]
fn emoji_clusters_are_recognised() {
let c = |s: &str| s.chars().collect::<Vec<char>>();
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "1\u{FE0F}\u{20E3}", "🏳\u{FE0F}\u{200D}🌈"] {
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
}
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
}
}
#[test]
fn colour_fonts_are_recognised() {
let db = FontDb::with_font_dirs(Vec::new());
assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0);
assert!(db.face("DeckCraft Colr Squares", "Regular").is_color());
assert!(!fontdb::last_resort_face().is_color());
}
#[test]
fn last_resort_face_parses() {
let f = fontdb::last_resort_face();
@@ -163,6 +184,65 @@ mod tests {
assert!(!shape(&f, "Hello", &[], |c| c).is_empty());
}
#[test]
fn windows_family_names_find_their_faces() {
// Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's
// typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way.
let check = |db: &FontDb| {
assert!(db.has_family("DeckCraft Legacy Semibold"));
for style in ["Regular", "Bold"] {
let f = db.face("DeckCraft Legacy Semibold", style);
assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}");
}
assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri");
// The typographic name still works.
assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold");
};
// Loaded directly…
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1);
check(&db);
// …and found by the system font scan under its Windows name, then loaded.
#[cfg(not(target_arch = "wasm32"))]
{
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let db = FontDb::with_font_dirs(vec![dir.clone()]);
db.set_system_fallback(false);
check(&db);
// Looking up the Windows name loads the Semibold file only; a later lookup of the
// family's Regular must still load and pick the Regular file, not settle for Semibold.
let db = FontDb::with_font_dirs(vec![dir]);
db.set_system_fallback(false);
assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold");
let regular = db.face("DeckCraft Legacy", "Regular");
assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0));
}
}
#[test]
fn missing_document_fonts_are_drawn_in_calibri() {
// PowerPoint draws a missing family in Calibri, whatever its panose or the theme says.
let db = FontDb::global();
for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] {
let f = db.document_face(missing, "Bold");
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
}
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
for (missing, twin) in
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
{
if !db.has_family(missing) {
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
}
}
// Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks.
// So do Office's cloud fonts, which PowerPoint downloads and draws as themselves.
for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] {
assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}");
}
}
#[test]
fn office_fonts_have_substitutes() {
assert!(substitutes("Calibri").contains(&"Carlito"));
Binary file not shown.
Binary file not shown.
+55
View File
@@ -0,0 +1,55 @@
"""Build the legacy-name test font: DeckCraft Legacy Semibold (contributor-original, CC0-1.0).
Usage: python make_legacy_font.py legacy-semibold.ttf legacy-regular.ttf (needs fontTools)
legacy-semibold.ttf: typographic family "DeckCraft Legacy", style "Semibold" (name IDs 16/17,
weight 600); Windows family "DeckCraft Legacy Semibold", style "Regular" (name IDs 1/2), as fonts
like Segoe UI Semibold are named. legacy-regular.ttf: the family's Regular ("DeckCraft Legacy",
"Regular", weight 400), named only by IDs 1/2 as Segoe UI's regular file is. Glyphs: .notdef and
a square for "A".
"""
import sys
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
def square():
pen = TTGlyphPen(None)
pen.moveTo((100, 0))
pen.lineTo((100, 700))
pen.lineTo((600, 700))
pen.lineTo((600, 0))
pen.closePath()
return pen.glyph()
def build(path, names, weight):
fb = FontBuilder(1000, isTTF=True)
fb.setupGlyphOrder([".notdef", "A"])
fb.setupCharacterMap({ord("A"): "A"})
fb.setupGlyf({".notdef": TTGlyphPen(None).glyph(), "A": square()})
fb.setupHorizontalMetrics({".notdef": (500, 0), "A": (700, 100)})
fb.setupHorizontalHeader(ascent=800, descent=-200)
fb.setupNameTable(names)
fb.setupOS2(usWeightClass=weight, sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200)
fb.setupPost()
fb.save(path)
build(sys.argv[1], {
"familyName": "DeckCraft Legacy Semibold",
"styleName": "Regular",
"typographicFamily": "DeckCraft Legacy",
"typographicSubfamily": "Semibold",
"uniqueFontIdentifier": "DeckCraft Legacy Semibold",
"fullName": "DeckCraft Legacy Semibold",
"psName": "DeckCraftLegacy-Semibold",
}, 600)
build(sys.argv[2], {
"familyName": "DeckCraft Legacy",
"styleName": "Regular",
"uniqueFontIdentifier": "DeckCraft Legacy Regular",
"fullName": "DeckCraft Legacy",
"psName": "DeckCraftLegacy-Regular",
}, 400)
+1
View File
@@ -8,6 +8,7 @@
pub mod morph;
pub mod preset;
pub mod warp;
pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2};
use serde::{Deserialize, Serialize};
+46 -1
View File
@@ -9,6 +9,8 @@
use kurbo::{Arc, BezPath, Point, Rect, Shape as _, Vec2};
use serde::{Deserialize, Serialize};
mod extra;
/// How a sub-path is filled. `Lighten`/`Darken` variants shade the shape's fill (3-D looking faces).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -148,6 +150,7 @@ pub static CATALOG: &[PresetInfo] = &[
p!("bentConnector3", "Connector: Elbow", Lines, [50000]),
p!("curvedConnector3", "Connector: Curved", Lines, [50000]),
p!("bentConnector2", "Connector: Elbow (one bend)", Lines, []),
p!("lineInv", "Line: Rising", Lines, []),
p!("rect", "Rectangle", Rectangles, []),
p!("roundRect", "Rectangle: Rounded Corners", Rectangles, [16667]),
p!("snip1Rect", "Rectangle: Single Corner Snipped", Rectangles, [16667]),
@@ -163,6 +166,7 @@ pub static CATALOG: &[PresetInfo] = &[
p!("rtTriangle", "Right Triangle", BasicShapes, []),
p!("parallelogram", "Parallelogram", BasicShapes, [25000]),
p!("trapezoid", "Trapezoid", BasicShapes, [25000]),
p!("nonIsoscelesTrapezoid", "Trapezoid: Uneven Sides", BasicShapes, [25000, 25000]),
p!("diamond", "Diamond", BasicShapes, []),
p!("pentagon", "Regular Pentagon", BasicShapes, []),
p!("hexagon", "Hexagon", BasicShapes, [25000]),
@@ -171,9 +175,13 @@ pub static CATALOG: &[PresetInfo] = &[
p!("decagon", "Decagon", BasicShapes, []),
p!("dodecagon", "Dodecagon", BasicShapes, []),
p!("pie", "Partial Circle", BasicShapes, [0, 16200000]),
p!("pieWedge", "Quarter Wedge", BasicShapes, []),
p!("chord", "Chord", BasicShapes, [2700000, 16200000]),
p!("teardrop", "Teardrop", BasicShapes, [100000]),
p!("frame", "Frame", BasicShapes, [12500]),
p!("cornerTabs", "Corner Tabs: Triangles", BasicShapes, []),
p!("squareTabs", "Corner Tabs: Squares", BasicShapes, []),
p!("plaqueTabs", "Corner Tabs: Quarter Circles", BasicShapes, []),
p!("halfFrame", "Half Frame", BasicShapes, [33333, 33333]),
p!("corner", "L-Shape", BasicShapes, [50000, 50000]),
p!("diagStripe", "Diagonal Stripe", BasicShapes, [50000]),
@@ -192,6 +200,9 @@ pub static CATALOG: &[PresetInfo] = &[
p!("sun", "Sun", BasicShapes, [25000]),
p!("moon", "Moon", BasicShapes, [50000]),
p!("cloud", "Cloud", BasicShapes, []),
p!("gear6", "Gear: 6 Teeth", BasicShapes, [15000, 3526]),
p!("gear9", "Gear: 9 Teeth", BasicShapes, [10000, 1763]),
p!("funnel", "Funnel", BasicShapes, []),
p!("arc", "Arc", BasicShapes, [16200000, 0]),
p!("bracketPair", "Double Bracket", BasicShapes, [16667]),
p!("bracePair", "Double Brace", BasicShapes, [8333]),
@@ -199,6 +210,9 @@ pub static CATALOG: &[PresetInfo] = &[
p!("rightBracket", "Right Bracket", BasicShapes, [8333]),
p!("leftBrace", "Left Brace", BasicShapes, [8333, 50000]),
p!("rightBrace", "Right Brace", BasicShapes, [8333, 50000]),
p!("chartX", "Chart Marker: Cross Lines", BasicShapes, []),
p!("chartStar", "Chart Marker: Star Lines", BasicShapes, []),
p!("chartPlus", "Chart Marker: Plus Lines", BasicShapes, []),
p!("rightArrow", "Arrow: Right", BlockArrows, [50000, 50000]),
p!("leftArrow", "Arrow: Left", BlockArrows, [50000, 50000]),
p!("upArrow", "Arrow: Up", BlockArrows, [50000, 50000]),
@@ -209,16 +223,27 @@ pub static CATALOG: &[PresetInfo] = &[
p!("leftRightUpArrow", "Arrow: Left-Right-Up", BlockArrows, [25000, 25000, 25000]),
p!("bentArrow", "Arrow: Bent", BlockArrows, [25000, 25000, 25000, 43750]),
p!("uturnArrow", "Arrow: U-Turn", BlockArrows, [25000, 25000, 25000, 43750, 75000]),
p!("curvedRightArrow", "Arrow: Curved Right", BlockArrows, [25000, 50000, 25000]),
p!("curvedLeftArrow", "Arrow: Curved Left", BlockArrows, [25000, 50000, 25000]),
p!("curvedUpArrow", "Arrow: Curved Up", BlockArrows, [25000, 50000, 25000]),
p!("curvedDownArrow", "Arrow: Curved Down", BlockArrows, [25000, 50000, 25000]),
p!("leftUpArrow", "Arrow: Left-Up", BlockArrows, [25000, 25000, 25000]),
p!("bentUpArrow", "Arrow: Bent-Up", BlockArrows, [25000, 25000, 25000]),
p!("stripedRightArrow", "Arrow: Striped Right", BlockArrows, [50000, 50000]),
p!("notchedRightArrow", "Arrow: Notched Right", BlockArrows, [50000, 50000]),
p!("homePlate", "Arrow: Pentagon", BlockArrows, [50000]),
p!("chevron", "Arrow: Chevron", BlockArrows, [50000]),
p!("circularArrow", "Arrow: Circular", BlockArrows, [12500, 1142319, 20457681, 10800000, 12500]),
p!("leftCircularArrow", "Arrow: Circular Counter-clockwise", BlockArrows, [12500, -1142319, 1142319, 10800000, 12500]),
p!("leftRightCircularArrow", "Arrow: Circular Two-headed", BlockArrows, [12500, 1142319, 20457681, 11942319, 12500]),
p!("swooshArrow", "Arrow: Swoosh", BlockArrows, [25000, 16667]),
p!("rightArrowCallout", "Callout: Right Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("downArrowCallout", "Callout: Down Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("leftArrowCallout", "Callout: Left Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("upArrowCallout", "Callout: Up Arrow", BlockArrows, [25000, 25000, 25000, 64977]),
p!("leftRightArrowCallout", "Callout: Left-Right Arrow", BlockArrows, [25000, 25000, 25000, 48123]),
p!("upDownArrowCallout", "Callout: Up-Down Arrow", BlockArrows, [25000, 25000, 25000, 48123]),
p!("quadArrowCallout", "Callout: Quad Arrow", BlockArrows, [18515, 18515, 18515, 48123]),
p!("mathPlus", "Plus Sign", EquationShapes, [23520]),
p!("mathMinus", "Minus Sign", EquationShapes, [23520]),
p!("mathMultiply", "Multiplication Sign", EquationShapes, [23520]),
@@ -253,6 +278,7 @@ pub static CATALOG: &[PresetInfo] = &[
p!("flowChartMagneticDisk", "Flowchart: Magnetic Disk", Flowchart, []),
p!("flowChartMagneticDrum", "Flowchart: Direct Access Storage", Flowchart, []),
p!("flowChartDisplay", "Flowchart: Display", Flowchart, []),
p!("flowChartOfflineStorage", "Flowchart: Offline Storage", Flowchart, []),
p!("irregularSeal1", "Explosion: 8 Points", StarsAndBanners, []),
p!("irregularSeal2", "Explosion: 14 Points", StarsAndBanners, []),
p!("star4", "Star: 4 Points", StarsAndBanners, [12500]),
@@ -267,6 +293,9 @@ pub static CATALOG: &[PresetInfo] = &[
p!("star32", "Star: 32 Points", StarsAndBanners, [37500]),
p!("ribbon2", "Ribbon: Tilted Up", StarsAndBanners, [16667, 50000]),
p!("ribbon", "Ribbon: Tilted Down", StarsAndBanners, [16667, 50000]),
p!("ellipseRibbon2", "Ribbon: Curved and Tilted Up", StarsAndBanners, [25000, 50000, 12500]),
p!("ellipseRibbon", "Ribbon: Curved and Tilted Down", StarsAndBanners, [25000, 50000, 12500]),
p!("leftRightRibbon", "Ribbon: Left-Right Fold", StarsAndBanners, [50000, 50000, 16667]),
p!("verticalScroll", "Scroll: Vertical", StarsAndBanners, [12500]),
p!("horizontalScroll", "Scroll: Horizontal", StarsAndBanners, [12500]),
p!("wave", "Wave", StarsAndBanners, [12500, 0]),
@@ -276,6 +305,22 @@ pub static CATALOG: &[PresetInfo] = &[
p!("wedgeEllipseCallout", "Speech Bubble: Oval", Callouts, [-20833, 62500]),
p!("cloudCallout", "Thought Bubble: Cloud", Callouts, [-20833, 62500]),
p!("borderCallout1", "Callout: Line", Callouts, [18750, -8333, 112500, -38333]),
p!("borderCallout2", "Callout: Bent Line", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!("borderCallout3", "Callout: Double Bent Line", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
p!("accentCallout1", "Callout: Line with Accent Bar", Callouts, [18750, -8333, 112500, -38333]),
p!("accentCallout2", "Callout: Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!("accentCallout3", "Callout: Double Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
p!("callout1", "Callout: Line with No Border", Callouts, [18750, -8333, 112500, -38333]),
p!("callout2", "Callout: Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!("callout3", "Callout: Double Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]),
p!("accentBorderCallout1", "Callout: Line with Border and Accent Bar", Callouts, [18750, -8333, 112500, -38333]),
p!("accentBorderCallout2", "Callout: Bent Line with Border and Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]),
p!(
"accentBorderCallout3",
"Callout: Double Bent Line with Border and Accent Bar",
Callouts,
[18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]
),
p!("actionButtonBackPrevious", "Action Button: Go Back or Previous", ActionButtons, []),
p!("actionButtonForwardNext", "Action Button: Go Forward or Next", ActionButtons, []),
p!("actionButtonBeginning", "Action Button: Go to Beginning", ActionButtons, []),
@@ -1535,7 +1580,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
let l = Pb::new().m(0.0, 0.0).c(x / 2.0, 0.0, x, h / 4.0, x, h / 2.0).c(x, h * 0.75, (x + w) / 2.0, h, w, h).take();
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] }
}
_ => return None,
_ => return extra::build(name, w, h, &a),
};
Some(g)
}
+796
View File
@@ -0,0 +1,796 @@
//! More preset outlines: line callouts, arrow callouts, curved and circular arrows, curved
//! ribbons, gears, tabs and chart markers. Like the rest of `preset`, every outline is our own
//! construction, fitted to how the shapes look and to what their adjust values mean.
use super::*;
/// Builds the presets this module knows; `None` for any other name.
pub(super) fn build(name: &str, w: f64, h: f64, a: &dyn Fn(usize) -> f64) -> Option<Geometry> {
let ss = w.min(h);
let full = Rect::new(0.0, 0.0, w, h);
let mut b = Pb::new();
let g = match name {
"lineInv" => {
let l = b.m(0.0, h).l(w, 0.0).take();
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::new(0.0, h), Point::new(w, 0.0)] }
}
"nonIsoscelesTrapezoid" => {
let max = 50000.0 * w / ss.max(1e-9);
let (v1, v2) = (a(0).clamp(0.0, max), a(1).clamp(0.0, max));
let (d1, d2) = (ss * v1 / 1e5, ss * v2 / 1e5);
let mut g = simple(b.poly(&[(0.0, h), (d1, 0.0), (w - d2, 0.0), (w, h)]).take(), Rect::new(d1 * 0.66, h * 0.2, w - d2 * 0.66, h), w, h);
g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 1e5, 0.0), v1, 0.0, max));
g.handles.push(Handle::new(1, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v2, 0.0, max));
g
}
"pieWedge" => {
b.m(0.0, h).arc(w, h, 180.0, 90.0).l(w, h).z();
simple(b.take(), Rect::new(w * 0.29289, h * 0.29289, w, h), w, h)
}
"flowChartOfflineStorage" => {
let tri = b.poly(&[(0.0, 0.0), (w, 0.0), (w / 2.0, h)]).take();
let bar = Pb::new().m(w * 0.4, h * 0.8).l(w * 0.6, h * 0.8).take();
Geometry {
paths: vec![fill(tri), stroke_only(bar)],
text_rect: Rect::new(w / 4.0, 0.0, w * 0.75, h * 0.8),
handles: vec![],
sites: vec![Point::new(w / 2.0, 0.0), Point::new(w / 4.0, h / 2.0), Point::new(w / 2.0, h), Point::new(w * 0.75, h / 2.0)],
}
}
"chartX" | "chartStar" | "chartPlus" => {
let body = corner_rect(w, h, [Corner::Square; 4]);
let mut l = Pb::new();
if name != "chartPlus" {
l.m(0.0, 0.0).l(w, h).m(0.0, h).l(w, 0.0);
}
if name != "chartX" {
l.m(w / 2.0, 0.0).l(w / 2.0, h);
}
if name == "chartPlus" {
l.m(0.0, h / 2.0).l(w, h / 2.0);
}
Geometry { paths: vec![fill_only(body), stroke_only(l.take())], text_rect: full, handles: vec![], sites: mid_sites(w, h) }
}
"cornerTabs" | "squareTabs" | "plaqueTabs" => {
// Each tab's size is a twentieth of the diagonal.
let d = (w.hypot(h) / 20.0).min(w / 2.0).min(h / 2.0);
match name {
"cornerTabs" => {
b.poly(&[(0.0, 0.0), (d, 0.0), (0.0, d)]);
b.poly(&[(w - d, 0.0), (w, 0.0), (w, d)]);
b.poly(&[(w, h - d), (w, h), (w - d, h)]);
b.poly(&[(0.0, h - d), (d, h), (0.0, h)]);
}
"squareTabs" => {
b.poly(&[(0.0, 0.0), (d, 0.0), (d, d), (0.0, d)]);
b.poly(&[(w - d, 0.0), (w, 0.0), (w, d), (w - d, d)]);
b.poly(&[(w - d, h - d), (w, h - d), (w, h), (w - d, h)]);
b.poly(&[(0.0, h - d), (d, h - d), (d, h), (0.0, h)]);
}
_ => {
if d > 0.0 {
b.m(0.0, 0.0).l(d, 0.0).arc(d, d, 0.0, 90.0).z();
b.m(w, 0.0).l(w, d).arc(d, d, 90.0, 90.0).z();
b.m(w, h).l(w - d, h).arc(d, d, 180.0, 90.0).z();
b.m(0.0, h).l(0.0, h - d).arc(d, d, 270.0, 90.0).z();
} else {
b.m(0.0, 0.0).z();
}
}
}
Geometry { paths: vec![fill(b.take())], text_rect: full.inset(-d), handles: vec![], sites: mid_sites(w, h) }
}
"callout1"
| "callout2"
| "callout3"
| "accentCallout1"
| "accentCallout2"
| "accentCallout3"
| "borderCallout2"
| "borderCallout3"
| "accentBorderCallout1"
| "accentBorderCallout2"
| "accentBorderCallout3" => {
let n = match name.chars().last() {
Some('2') => 2,
Some('3') => 3,
_ => 1,
};
line_callout(w, h, a, n + 1, name.starts_with("accent"), name.contains("order"))
}
"leftRightArrowCallout" | "upDownArrowCallout" => {
let vertical = name == "upDownArrowCallout";
let (l, t) = if vertical { (h, w) } else { (w, h) };
let ss = l.min(t);
let head = (ss * a(1).max(0.0) / 1e5).min(t / 2.0);
let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0);
let hl = (ss * a(2).max(0.0) / 1e5).min(l / 2.0);
let boxl = (l * a(3).max(0.0) / 1e5).min(l - 2.0 * hl);
let (c, b0, b1) = (t / 2.0, (l - boxl) / 2.0, (l + boxl) / 2.0);
let pts = [
(0.0, c),
(hl, c - head),
(hl, c - shaft / 2.0),
(b0, c - shaft / 2.0),
(b0, 0.0),
(b1, 0.0),
(b1, c - shaft / 2.0),
(l - hl, c - shaft / 2.0),
(l - hl, c - head),
(l, c),
(l - hl, c + head),
(l - hl, c + shaft / 2.0),
(b1, c + shaft / 2.0),
(b1, t),
(b0, t),
(b0, c + shaft / 2.0),
(hl, c + shaft / 2.0),
(hl, c + head),
];
let d = if vertical { Dir::Down } else { Dir::Right };
let pts = orient(d, w, h, &pts);
let tr = orient(d, w, h, &[(b0, 0.0), (b1, t)]);
simple(b.poly(&pts).take(), Rect::from_points(Point::new(tr[0].0, tr[0].1), Point::new(tr[1].0, tr[1].1)).abs(), w, h)
}
"quadArrowCallout" => {
let head = (ss * a(1).max(0.0) / 1e5).min(ss / 2.0);
let shaft = (ss * a(0).max(0.0) / 1e5).min(head * 2.0);
let hl = (ss * a(2).max(0.0) / 1e5).min(ss / 2.0);
let k = a(3).clamp(0.0, 100000.0) / 1e5;
let (bw, bh) = ((w * k).min(w - 2.0 * hl).max(shaft), (h * k).min(h - 2.0 * hl).max(shaft));
let (cx, cy) = (w / 2.0, h / 2.0);
let (bx0, bx1, by0, by1) = (cx - bw / 2.0, cx + bw / 2.0, cy - bh / 2.0, cy + bh / 2.0);
let (s2, hd) = (shaft / 2.0, head);
let pts = [
(0.0, cy),
(hl, cy - hd),
(hl, cy - s2),
(bx0, cy - s2),
(bx0, by0),
(cx - s2, by0),
(cx - s2, hl),
(cx - hd, hl),
(cx, 0.0),
(cx + hd, hl),
(cx + s2, hl),
(cx + s2, by0),
(bx1, by0),
(bx1, cy - s2),
(w - hl, cy - s2),
(w - hl, cy - hd),
(w, cy),
(w - hl, cy + hd),
(w - hl, cy + s2),
(bx1, cy + s2),
(bx1, by1),
(cx + s2, by1),
(cx + s2, h - hl),
(cx + hd, h - hl),
(cx, h),
(cx - hd, h - hl),
(cx - s2, h - hl),
(cx - s2, by1),
(bx0, by1),
(bx0, cy + s2),
(hl, cy + s2),
(hl, cy + hd),
];
simple(b.poly(&pts).take(), Rect::new(bx0, by0, bx1, by1), w, h)
}
"curvedRightArrow" | "curvedLeftArrow" | "curvedUpArrow" | "curvedDownArrow" => curved_arrow(name, w, h, a(0), a(1), a(2)),
"swooshArrow" => swoosh(w, h, a(0), a(1)),
"ellipseRibbon" | "ellipseRibbon2" => ellipse_ribbon(name == "ellipseRibbon2", w, h, a(0), a(1), a(2)),
"leftRightRibbon" => left_right_ribbon(w, h, a(0), a(1), a(2)),
"gear6" | "gear9" => gear(if name == "gear6" { 6 } else { 9 }, w, h, a(0), a(1)),
"funnel" => funnel(w, h),
"circularArrow" | "leftCircularArrow" | "leftRightCircularArrow" => circular_arrow(name, w, h, [a(0), a(1), a(2), a(3), a(4)]),
_ => return None,
};
Some(g)
}
/// An ellipse-centred frame for the circular arrows: the band's middle runs on the ellipse
/// `(rx, ry)`, its edges `th/2` either side.
struct Ring {
c: Point,
rx: f64,
ry: f64,
th: f64,
hw: f64,
}
impl Ring {
/// The point at visual angle `deg` on the ellipse grown by `d`.
fn at(&self, deg: f64, d: f64) -> Point {
let (ax, ay) = ((self.rx + d).max(0.0), (self.ry + d).max(0.0));
let t = vis(deg, ax, ay).to_radians();
Point::new(self.c.x + ax * t.cos(), self.c.y + ay * t.sin())
}
/// Parametric angle (degrees) of `p` on the ellipse grown by `d`.
fn param(&self, p: Point, d: f64) -> f64 {
let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9));
((p.y - self.c.y) / ay).atan2((p.x - self.c.x) / ax).to_degrees()
}
/// Where the line `b + s·u` meets the ellipse grown by `d`, taking the crossing nearest `b`
/// on the side `sign` (+1 outwards, −1 inwards).
fn cross(&self, b: Point, u: Vec2, d: f64, sign: f64) -> Point {
let (ax, ay) = ((self.rx + d).max(1e-9), (self.ry + d).max(1e-9));
let (px, py) = ((b.x - self.c.x) / ax, (b.y - self.c.y) / ay);
let (ux, uy) = (u.x / ax, u.y / ay);
let qa = ux * ux + uy * uy;
let qb = 2.0 * (px * ux + py * uy);
let qc = px * px + py * py - 1.0;
let disc = qb * qb - 4.0 * qa * qc;
if qa < 1e-18 || disc < 0.0 {
// The line misses (a long head on a thin ring): the edge then carries on from the
// base centre itself, as the reference does.
return b;
}
let r = disc.sqrt();
let (s0, s1) = ((-qb - r) / (2.0 * qa), (-qb + r) / (2.0 * qa));
let s = if sign > 0.0 {
if s0 >= 0.0 { s0 } else { s1 }
} else if s1 <= 0.0 {
s1
} else {
s0
};
b + u * s
}
/// An arrow head whose base centre sits on the middle ellipse at `base` degrees and whose tip
/// sits there at `base + sweep`. The base runs along the line from the centre to the tip.
/// Returns (band outer edge, outer wing, tip, inner wing, band inner edge).
fn head(&self, base: f64, sweep: f64) -> [Point; 5] {
let b = self.at(base, 0.0);
let tip = self.at(base + sweep, 0.0);
let d = tip - self.c;
let u = if d.hypot() > 1e-12 { d / d.hypot() } else { Vec2::new(1.0, 0.0) };
let half = self.th / 2.0;
[self.cross(b, u, half, 1.0), b + u * self.hw, tip, b - u * self.hw, self.cross(b, u, -half, -1.0)]
}
}
/// Sweep (degrees) from `a` to `b` turning one way: positive when `dir > 0`.
fn turn(a: f64, b: f64, dir: f64) -> f64 {
if dir > 0.0 { (b - a).rem_euclid(360.0) } else { -(a - b).rem_euclid(360.0) }
}
/// The circular arrows: a band along an ellipse with one head (`circularArrow` clockwise,
/// `leftCircularArrow` anticlockwise) or a head at each end (`leftRightCircularArrow`).
/// Adjusts: band thickness, head sweep (degrees), end angle, start angle, head half-width.
fn circular_arrow(name: &str, w: f64, h: f64, a: [f64; 5]) -> Geometry {
let ss = w.min(h);
if ss <= 0.0 {
return simple(corner_rect(w, h, [Corner::Square; 4]), Rect::new(0.0, 0.0, w, h), w, h);
}
let a5 = a[4].clamp(0.0, 25000.0);
let hw = ss * a5 / 1e5;
let th = ss * a[0].clamp(0.0, 2.0 * a5) / 1e5;
let ring = Ring { c: Point::new(w / 2.0, h / 2.0), rx: (w / 2.0 - hw).max(th / 2.0), ry: (h / 2.0 - hw).max(th / 2.0), th, hw };
let deg = |v: f64| (v / 60000.0) % 360.0;
let (sweep, end, start) = (deg(a[1]), deg(a[2]), deg(a[3]));
let half = th / 2.0;
let mut b = Pb::new();
// Arcs of the ellipse grown by `d`, drawn on from the current point (which is on it except in
// the degenerate case above, where the arc shifts with it like the reference's).
let edge_arc = |b: &mut Pb, from: Point, to: Point, d: f64, dir: f64| {
let (ax, ay) = ((ring.rx + d).max(0.0), (ring.ry + d).max(0.0));
let (s, e) = (ring.param(from, d), ring.param(to, d));
b.arc(ax, ay, s, turn(s, e, dir));
};
let outer_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, half, dir);
let inner_arc = |b: &mut Pb, from: Point, to: Point, dir: f64| edge_arc(b, from, to, -half, dir);
if name == "leftRightCircularArrow" {
let hs = ring.head(start, -sweep);
let he = ring.head(end, sweep);
b.m(hs[0].x, hs[0].y);
outer_arc(&mut b, hs[0], he[0], 1.0);
b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y);
inner_arc(&mut b, he[4], hs[4], -1.0);
b.l(hs[3].x, hs[3].y).l(hs[2].x, hs[2].y).l(hs[1].x, hs[1].y).z();
} else {
let dir = if name == "circularArrow" { 1.0 } else { -1.0 };
let he = ring.head(end, sweep);
let (o0, i0) = (ring.at(start, half), ring.at(start, -half));
b.m(o0.x, o0.y);
outer_arc(&mut b, o0, he[0], dir);
b.l(he[1].x, he[1].y).l(he[2].x, he[2].y).l(he[3].x, he[3].y).l(he[4].x, he[4].y);
inner_arc(&mut b, he[4], i0, -dir);
b.z();
}
simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h)
}
/// A funnel: an elliptical rim (with an open mouth) across the top quarter, and straight sides
/// down to a small rounded spout. The side lines leave the rim and meet the spout at fixed
/// angles, fitted to the reference look.
fn funnel(w: f64, h: f64) -> Geometry {
const RIM: f64 = 173.0; // where the left side leaves the rim, degrees on the rim ellipse
const SPOUT: f64 = 163.4; // where it meets the spout, degrees on the spout ellipse
let ss = w.min(h);
let (cx, rim_y) = (w / 2.0, h / 4.0);
let (sx, sy) = (w / 8.0, h / 15.0);
let spout_y = h - sy;
let at = |rx: f64, ry: f64, cy: f64, deg: f64| {
let t = deg.to_radians();
Point::new(cx + rx * t.cos(), cy + ry * t.sin())
};
let p0 = at(cx, rim_y, rim_y, RIM);
let p1 = at(sx, sy, spout_y, 180.0 - SPOUT);
let mut b = Pb::new();
b.m(p0.x, p0.y).arc(cx, rim_y, RIM, 540.0 - 2.0 * RIM);
b.l(p1.x, p1.y).arc(sx, sy, 180.0 - SPOUT, 2.0 * SPOUT - 180.0).z();
let d = ss / 20.0;
b.ellipse(cx, rim_y, (cx - d).max(0.0), (rim_y - d).max(0.0));
Geometry { paths: vec![fill_eo(b.take())], text_rect: Rect::new(w / 4.0, h / 2.0, w * 0.75, h - sy), handles: vec![], sites: mid_sites(w, h) }
}
/// A gear: an elliptical body with `n` teeth, the first pointing straight up. Each tooth leaves
/// the body at two points an equal angle either side of its centre, and its tip corners sit a
/// tooth height further out along the body's normal and a flank lean further in.
fn gear(n: usize, w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
let ss = w.min(h);
let th = ss * a0.clamp(0.0, 20000.0) / 1e5; // tooth height
let lean_max = if n == 6 { 5380.0 } else { 2730.0 };
let l = ss * a1.clamp(0.0, a0.clamp(0.0, 20000.0).min(lean_max)) / 1e5; // flank lean
let (cx, cy) = (w / 2.0, h / 2.0);
let (rx, ry) = ((cx - th).max(0.0), (cy - th).max(0.0));
let r = (ss / 2.0 - th).max(1e-9);
let half = ((th + l) / 2.0 / r).atan();
let step = std::f64::consts::TAU / n as f64;
let on_body = |t: f64| Point::new(cx + rx * t.cos(), cy + ry * t.sin());
let mut b = Pb::new();
for k in 0..n {
let t = -std::f64::consts::FRAC_PI_2 + step * k as f64;
// The tooth stands along the body's normal at its centre.
let nrm = Vec2::new(t.cos() * ry, t.sin() * rx);
let u = if nrm.hypot() > 1e-12 { nrm / nrm.hypot() } else { Vec2::new(t.cos(), t.sin()) };
let v = Vec2::new(-u.y, u.x);
let (r0, r1) = (on_body(t - half), on_body(t + half));
let (c0, c1) = (r0 + u * th + v * l, r1 + u * th - v * l);
if k == 0 {
b.m(r0.x, r0.y);
} else {
b.arc(rx, ry, (t - step + half).to_degrees(), (step - 2.0 * half).to_degrees());
}
b.l(c0.x, c0.y).l(c1.x, c1.y).l(r1.x, r1.y);
}
let t_last = -std::f64::consts::FRAC_PI_2 + step * (n - 1) as f64;
b.arc(rx, ry, (t_last + half).to_degrees(), (step - 2.0 * half).to_degrees()).z();
let k = std::f64::consts::FRAC_1_SQRT_2;
let text = Rect::new(cx - rx * k, cy - ry * k, cx + rx * k, cy + ry * k);
simple(b.take(), text, w, h)
}
/// Two arrows on a band that steps down in the middle: the left arrow sits high, the right one low,
/// and the band rolls over between them in a shaded half-ellipse curl.
fn left_right_ribbon(w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
let ss = w.min(h);
let sh = h * a2.clamp(0.0, 33333.0) / 1e5; // step between the two halves
let bt = (h * a0.max(0.0) / 1e5).min(h - sh); // band thickness
let cx = w / 2.0;
let (rx, ry) = (w / 32.0, (sh / 4.0).min(w / 24.0));
let hb = (ss * a1.max(0.0) / 1e5).min(cx - rx); // head length
let yc = (h - sh) / 2.0;
let (lt, lb) = (yc - bt / 2.0, yc + bt / 2.0);
let (rt, rb) = (lt + sh, lb + sh);
// The left half: filled as a closed outline, stroked only where it can show.
let mut edge = Pb::new();
edge.m(cx - rx, lb).l(hb, lb).l(hb, h - sh).l(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt);
edge.arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -90.0);
let edge = edge.take();
let mut left = Pb::new();
left.m(0.0, yc).l(hb, 0.0).l(hb, lt).l(cx, lt).arc(rx, ry, 270.0, 180.0).arc(rx, ry, 270.0, -180.0);
left.l(cx, lb.max(lt + 4.0 * ry)).l(hb, lb).l(hb, h - sh).z();
let left = left.take();
let mut curl = Pb::new();
curl.m(cx + rx, lt + ry).l(cx + rx, rt).l(cx, lt + 4.0 * ry).arc(rx, ry, 90.0, 180.0).arc(rx, ry, 90.0, -90.0).z();
let mut right = Pb::new();
right.m(w, yc + sh).l(w - hb, h).l(w - hb, rb).l(cx, rb).arc(rx, ry, 90.0, 90.0).l(cx - rx, rt - ry).arc(rx, ry, 180.0, -90.0);
right.l(w - hb, rt).l(w - hb, sh).z();
Geometry {
paths: vec![fill_only(left), stroke_only(edge), shade(curl.take(), FillMode::DarkenLess), fill(right.take())],
text_rect: Rect::new(hb.min(cx - rx), rt, (w - hb).max(cx + rx), lb.max(rt)),
handles: vec![],
sites: vec![Point::new(0.0, yc), Point::new(cx, rt), Point::new(w, yc + sh), Point::new(cx, lb)],
}
}
/// Adds a piece of the parabola `y = y0 + d·(1 − ((x − w/2)/(w/2))²)` from the current point (at
/// `xa`) to `xb`, as one quadratic curve.
fn sag(b: &mut Pb, w: f64, d: f64, y0: f64, xa: f64, xb: f64) {
let hw = (w / 2.0).max(1e-9);
let f = |x: f64| y0 + d * (1.0 - ((x - hw) / hw).powi(2));
let slope = -2.0 * d * (xa - hw) / (hw * hw);
b.q((xa + xb) / 2.0, f(xa) + slope * (xb - xa) / 2.0, xb, f(xb));
}
/// A ribbon whose edges sag along one parabola: a front band in the middle and two notched ends
/// behind it, joined by shaded folds. Built sagging downwards; `up` mirrors it vertically.
fn ellipse_ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
let th = h * a0.clamp(0.0, 100000.0) / 1e5; // band drop at the centre
// Sag of every edge: never more than the drop, never so little that the band would rise
// above the ends by more than half their height.
let d = (h * a2.max(0.0) / 1e5).max(th - (h - th) / 2.0).clamp(0.0, th);
let bw = w * a1.clamp(25000.0, 75000.0) / 1e5;
let (x0, x1) = ((w - bw) / 2.0, (w + bw) / 2.0);
let ear = w / 8.0;
let (xf, xg) = (x0 + ear, x1 - ear);
let e = h - th; // band height
let off = th - d; // band top below the ends' top
let t = |x: f64| {
let hw = (w / 2.0).max(1e-9);
d * (1.0 - ((x - hw) / hw).powi(2))
};
let mut ends = Pb::new();
ends.m(0.0, 0.0);
sag(&mut ends, w, d, 0.0, 0.0, xf);
ends.l(xf, t(xf) + off).l(x0, t(x0) + off).l(x0, t(x0) + e);
sag(&mut ends, w, d, e, x0, 0.0);
ends.l(ear, t(ear) + e / 2.0).z();
ends.m(w, 0.0).l(w - ear, t(w - ear) + e / 2.0).l(w, e);
sag(&mut ends, w, d, e, w, x1);
ends.l(x1, t(x1) + off).l(xg, t(xg) + off).l(xg, t(xg));
sag(&mut ends, w, d, 0.0, xg, w);
ends.z();
let mut folds = Pb::new();
folds.poly(&[(x0, t(x0) + off), (xf, t(xf)), (xf, t(xf) + off)]);
folds.poly(&[(x1, t(x1) + off), (xg, t(xg)), (xg, t(xg) + off)]);
let mut band = Pb::new();
band.m(x0, t(x0) + off);
sag(&mut band, w, d, off, x0, x1);
band.l(x1, t(x1) + off + e);
sag(&mut band, w, d, off + e, x1, x0);
band.z();
let mut paths = vec![fill(ends.take()), shade(folds.take(), FillMode::DarkenLess), fill(band.take())];
let mut text = Rect::new(x0, th, x1, t(x0) + off + e);
if up {
let m = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, h]);
for p in &mut paths {
p.path.apply_affine(m);
}
text = m.transform_rect_bbox(text);
}
Geometry { paths, text_rect: text, handles: vec![], sites: mid_sites(w, h) }
}
/// A sweeping arrow rising from the bottom-left corner. The band's two edges are quadratic curves
/// from the corner; the head's base leans slightly forward and reaches one eighth of the shorter
/// side above and below the band. The tilt and the tip's lift were fitted to the reference look.
fn swoosh(w: f64, h: f64, a0: f64, a1: f64) -> Geometry {
const TILT: f64 = 0.1126; // tan of the head base's lean from vertical
const LIFT: f64 = 0.051; // tip height above the band's centre, per unit of height
let ss = w.min(h);
let e = ss / 8.0;
let th = (h * a0.max(0.0) / 1e5).min((h - 2.0 * e).max(0.0));
// The head is at least long enough to keep its leaning base inside the frame.
let hl = (ss * a1.max(0.0) / 1e5).max((e + th) * TILT).min((w - e * TILT).max(0.0));
let qx = w - hl;
let mut b = Pb::new();
b.m(0.0, h)
.q(w / 6.0, h / 3.0, qx, e)
.l(qx - e * TILT, 0.0)
.l(w, (e + th / 2.0 - LIFT * h).clamp(0.0, 2.0 * e + th))
.l((qx + (e + th) * TILT).min(w), 2.0 * e + th)
.l((qx + th * TILT).min(w), e + th)
.q(w / 4.0, (th + h / 12.0 + e).min(h), 0.0, h)
.z();
simple(b.take(), Rect::new(0.0, 0.0, w, h), w, h)
}
/// A band that sweeps around half an ellipse and ends in an arrow head. Built pointing right
/// (`curvedRightArrow`) in a `cw`×`ch` frame, then turned for the other directions.
///
/// Both halves of the band hang off one ellipse touching the far side: its centre sits on the
/// head side's edge, its horizontal radius is the full length, and the band has a constant
/// vertical thickness. The back half (shaded) starts flat against the head side; the front half
/// runs to the head, centred on the band where it ends.
fn curved_arrow(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry {
let along_x = matches!(name, "curvedRightArrow" | "curvedLeftArrow");
let (cw, ch) = if along_x { (w, h) } else { (h, w) };
let ss = cw.min(ch);
let head = (ss * a1.max(0.0) / 1e5).min(ch / 2.0);
let th = (ss * a0.max(0.0) / 1e5).min(head);
let hl = (ss * a2.max(0.0) / 1e5).min(cw);
let hh = head / 2.0;
// The band's centre line, carried on to the head side, ends at the tip, which sits half a
// head width above the bottom.
let r = ((ch - hh - th / 2.0) / 2.0).max(0.0);
// Parametric angle where the front band reaches the head base.
let te = if cw > 0.0 { (-(hl / cw)).clamp(-1.0, 1.0).acos().to_degrees() } else { 90.0 };
let mut back = Pb::new();
back.m(cw, 0.0).arc(cw, r, 270.0, -90.0).l(0.0, r + th).arc(cw, r, 180.0, 90.0).z();
let mut front = Pb::new();
front.m(0.0, r).arc(cw, r, 180.0, te - 180.0);
let yb = front.cur.y;
let yc = yb + th / 2.0;
front.l(cw - hl, yc - hh).l(cw, 2.0 * r + th / 2.0).l(cw - hl, yc + hh).l(cw - hl, yb + th).arc(cw, r, te, 180.0 - te).z();
let m = match name {
"curvedLeftArrow" => kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0]),
"curvedUpArrow" => kurbo::Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, h]),
"curvedDownArrow" => kurbo::Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]),
_ => kurbo::Affine::IDENTITY,
};
let (mut back, mut front) = (back.take(), front.take());
back.apply_affine(m);
front.apply_affine(m);
Geometry {
paths: vec![shade(back, FillMode::DarkenLess), fill(front)],
text_rect: Rect::new(0.0, 0.0, w, h),
handles: vec![],
sites: mid_sites(w, h),
}
}
/// A rectangle with a callout leader through `pts` points (`(y, x)` adjust pairs in 1/100 000 of
/// the height and width), optionally with an accent bar at the first point's x and a border.
fn line_callout(w: f64, h: f64, a: &dyn Fn(usize) -> f64, pts: usize, accent: bool, border: bool) -> Geometry {
let body = corner_rect(w, h, [Corner::Square; 4]);
// Leaders may reach well outside the box, but not without bound.
let pt = |i: usize| (w * a(2 * i + 1).clamp(-1e7, 1e7) / 1e5, h * a(2 * i).clamp(-1e7, 1e7) / 1e5);
let mut l = Pb::new();
let (x0, y0) = pt(0);
l.m(x0, y0);
for i in 1..pts {
let (x, y) = pt(i);
l.l(x, y);
}
let mut paths = vec![if border { fill(body) } else { fill_only(body) }];
if accent {
paths.push(stroke_only(Pb::new().m(x0, 0.0).l(x0, h).take()));
}
paths.push(stroke_only(l.take()));
Geometry { paths, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: mid_sites(w, h) }
}
#[cfg(test)]
mod tests {
use super::super::{CATALOG, FillMode, build, info};
use kurbo::{BezPath, PathEl, Point, Shape};
const NEW: [&str; 38] = [
"lineInv",
"nonIsoscelesTrapezoid",
"pieWedge",
"leftRightArrowCallout",
"upDownArrowCallout",
"quadArrowCallout",
"circularArrow",
"leftCircularArrow",
"leftRightCircularArrow",
"curvedRightArrow",
"curvedLeftArrow",
"curvedUpArrow",
"curvedDownArrow",
"swooshArrow",
"callout1",
"callout2",
"callout3",
"accentCallout1",
"accentCallout2",
"accentCallout3",
"borderCallout2",
"borderCallout3",
"accentBorderCallout1",
"accentBorderCallout2",
"accentBorderCallout3",
"ellipseRibbon",
"ellipseRibbon2",
"leftRightRibbon",
"flowChartOfflineStorage",
"gear6",
"gear9",
"funnel",
"cornerTabs",
"squareTabs",
"plaqueTabs",
"chartX",
"chartStar",
"chartPlus",
];
fn outline(name: &str, w: f64, h: f64, adj: &[f64]) -> BezPath {
build(name, w, h, adj).map(|g| g.outline()).unwrap_or_default()
}
/// Distance from `p` to the nearest point of the (flattened) outline.
fn dist(path: &BezPath, p: Point) -> f64 {
let mut best = f64::MAX;
let mut prev = Point::ZERO;
let mut start = Point::ZERO;
kurbo::flatten(path.iter(), 0.01, |el| {
let (a, b) = match el {
PathEl::MoveTo(q) => {
prev = q;
start = q;
return;
}
PathEl::LineTo(q) => (prev, q),
PathEl::ClosePath => (prev, start),
_ => return,
};
let d = b - a;
let t = if d.hypot2() > 0.0 { ((p - a).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
best = best.min((a + d * t - p).hypot());
prev = b;
});
best
}
/// Asserts every point lies within 1pt of the outline.
fn on(name: &str, w: f64, h: f64, adj: &[f64], pts: &[(f64, f64)]) {
let path = outline(name, w, h, adj);
for &(x, y) in pts {
let d = dist(&path, Point::new(x, y));
assert!(d < 1.0, "{name} {w}x{h} {adj:?}: ({x}, {y}) is {d:.2} from the outline");
}
}
#[test]
fn new_presets_are_in_the_catalog() {
for n in NEW {
let i = info(n).unwrap_or_else(|| panic!("{n} missing from the catalog"));
assert!(!i.label.is_empty());
}
assert!(CATALOG.len() >= 183);
}
#[test]
fn new_presets_build_within_bounds_at_many_sizes_and_adjusts() {
let sizes = [(480.0, 300.0), (300.0, 300.0), (200.0, 460.0), (100.0, 460.0), (1.0, 1.0), (0.0, 0.0), (0.0, 50.0), (60.0, 0.0)];
let adjusts: [&[f64]; 8] = [
&[],
&[0.0; 8],
&[100000.0; 8],
&[-50000.0; 8],
&[1e12, -1e12, 1e12, -1e12, 1e12, -1e12, 1e12, -1e12],
&[f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN, f64::MAX, f64::MIN],
&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 5.0, 6.0, 7.0, 8.0],
&[30000.0, 60000.0, 20000.0, 9000000.0, 20000.0, 40000.0, 120000.0, -30000.0],
];
for n in NEW {
// Line callouts' leaders may reach anywhere, by design.
let callout = n.contains("allout") && !n.contains("Arrow");
for (w, h) in sizes {
for adj in adjusts {
let g = build(n, w, h, adj).unwrap_or_else(|| panic!("{n} did not build"));
assert!(!g.paths.is_empty(), "{n} has no paths");
for s in &g.paths {
let bb = s.path.bounding_box();
assert!(bb.x0.is_finite() && bb.y0.is_finite() && bb.x1.is_finite() && bb.y1.is_finite(), "{n} {w}x{h} {adj:?}: {bb:?}");
if !callout {
// Circular arrows with a head longer than their ring can hold carry the
// inner edge on from the head, as the reference does, so may spill.
let spill = if n.contains("ircular") { 0.5 } else { 0.01 };
let tol = 0.5 + w.max(h) * spill;
assert!(
bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol,
"{n} {w}x{h} {adj:?} out of bounds: {bb:?}"
);
}
}
let t = g.text_rect;
assert!(t.x0.is_finite() && t.y0.is_finite() && t.x1.is_finite() && t.y1.is_finite(), "{n} text rect {t:?}");
}
}
let g = build(n, 480.0, 300.0, &[]).unwrap_or_else(|| panic!("{n}"));
assert!(g.outline().elements().len() >= 2, "{n} default outline is empty");
}
}
// The spot checks below are outline points measured on reference renders.
#[test]
fn simple_new_presets_match_reference_points() {
on("lineInv", 480.0, 300.0, &[], &[(0.0, 300.0), (480.0, 0.0)]);
on("nonIsoscelesTrapezoid", 480.0, 300.0, &[], &[(75.0, 0.0), (405.0, 0.0), (0.0, 300.0)]);
on("nonIsoscelesTrapezoid", 200.0, 460.0, &[80000.0, 20000.0], &[(100.0, 0.0), (160.0, 0.0)]);
on("pieWedge", 480.0, 300.0, &[], &[(64.3, 150.0), (0.0, 300.0), (480.0, 0.0)]);
on("flowChartOfflineStorage", 480.0, 300.0, &[], &[(240.0, 300.0), (192.0, 240.0), (288.0, 240.0)]);
on("squareTabs", 480.0, 300.0, &[], &[(28.3, 28.3), (451.7, 271.7)]);
on("cornerTabs", 480.0, 300.0, &[], &[(23.3, 5.0)]);
on("plaqueTabs", 480.0, 300.0, &[], &[(27.85, 5.0)]);
on("chartStar", 480.0, 300.0, &[], &[(240.0, 0.0), (240.0, 300.0), (48.0, 30.0)]);
on("chartPlus", 480.0, 300.0, &[], &[(0.0, 150.0), (240.0, 0.0)]);
// Chart markers and plain callouts leave the box unstroked.
for n in ["chartX", "callout1", "accentCallout2"] {
let g = build(n, 100.0, 100.0, &[]).unwrap_or_else(|| panic!("{n}"));
assert!(!g.paths[0].stroke, "{n}");
}
}
#[test]
fn line_callouts_follow_their_adjusts() {
on("callout3", 480.0, 300.0, &[], &[(-40.0, 56.25), (-80.0, 56.25), (-80.0, 300.0), (-40.0, 338.9)]);
let adj = [50000.0, 110000.0, 20000.0, 120000.0, -20000.0, 80000.0, 30000.0, 40000.0];
let g = build("accentBorderCallout3", 480.0, 300.0, &adj).unwrap_or_else(|| panic!("accentBorderCallout3"));
assert_eq!(g.paths.len(), 3);
assert!(g.paths[0].stroke);
let accent = g.paths[1].path.bounding_box();
assert!((accent.x0 - 528.0).abs() < 1e-6 && accent.y0 == 0.0 && accent.y1 == 300.0, "{accent:?}");
on("accentBorderCallout3", 480.0, 300.0, &adj, &[(576.0, 60.0), (384.0, -60.0), (192.0, 90.0)]);
}
#[test]
fn arrow_callouts_match_reference_points() {
on("leftRightArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (75.0, 75.0), (124.5, 0.0), (355.5, 300.0), (480.0, 150.0)]);
on("leftRightArrowCallout", 200.0, 460.0, &[], &[(50.0, 180.0), (52.0, 0.0), (148.0, 460.0)]);
on("upDownArrowCallout", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 77.8), (480.0, 222.2), (240.0, 300.0)]);
on("quadArrowCallout", 480.0, 300.0, &[], &[(0.0, 150.0), (124.5, 77.8), (240.0, 0.0), (480.0, 150.0), (240.0, 300.0)]);
}
#[test]
fn curved_arrows_match_reference_points() {
on(
"curvedRightArrow",
480.0,
300.0,
&[],
&[(480.0, 0.0), (480.0, 75.0), (480.0, 225.0), (405.0, 149.0), (405.0, 299.0), (100.0, 151.5), (300.0, 6.7)],
);
on("curvedRightArrow", 200.0, 460.0, &[30000.0, 60000.0, 40000.0], &[(200.0, 400.0), (120.0, 324.5), (120.0, 443.5), (200.0, 60.0)]);
// The band never outgrows the head; the head never outgrows half the frame.
on("curvedRightArrow", 200.0, 460.0, &[60000.0, 30000.0, 80000.0], &[(200.0, 60.0)]);
on("curvedLeftArrow", 480.0, 300.0, &[], &[(0.0, 225.0), (0.0, 0.0)]);
on("curvedUpArrow", 480.0, 300.0, &[], &[(405.0, 0.0), (0.0, 0.0), (75.0, 0.0)]);
on("curvedDownArrow", 480.0, 300.0, &[], &[(405.0, 300.0), (0.0, 300.0)]);
let g = build("curvedRightArrow", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("curvedRightArrow"));
assert_eq!(g.paths[0].fill, FillMode::DarkenLess);
}
#[test]
fn swoosh_matches_reference_points() {
on("swooshArrow", 480.0, 300.0, &[], &[(0.0, 300.0), (425.8, 0.0), (480.0, 59.7), (442.8, 150.0), (202.0, 105.5), (202.0, 159.0)]);
on("swooshArrow", 480.0, 300.0, &[40000.0, 16667.0], &[(480.0, 82.2), (447.8, 195.0)]);
on("swooshArrow", 200.0, 460.0, &[], &[(163.9, 0.0), (200.0, 59.4)]);
}
#[test]
fn ribbons_match_reference_points() {
on(
"ellipseRibbon",
480.0,
300.0,
&[],
&[(0.0, 0.0), (120.0, 28.1), (60.0, 128.9), (240.0, 300.0), (120.0, 66.1), (180.0, 72.2), (180.0, 35.2)],
);
on("ellipseRibbon", 480.0, 300.0, &[30000.0, 60000.0, 20000.0], &[(120.0, 45.0), (240.0, 300.0), (96.0, 68.4)]);
on("ellipseRibbon", 480.0, 300.0, &[80000.0, 20000.0, 60000.0], &[(240.0, 240.0), (1.0, 61.75)]);
on("ellipseRibbon2", 480.0, 300.0, &[], &[(0.0, 300.0), (240.0, 0.0)]);
on(
"leftRightRibbon",
480.0,
300.0,
&[],
&[(0.0, 125.0), (150.0, 0.0), (150.0, 50.0), (255.0, 62.5), (225.0, 87.5), (480.0, 175.0), (330.0, 300.0)],
);
on("leftRightRibbon", 480.0, 300.0, &[90000.0, 10000.0, 60000.0], &[(0.0, 100.0), (480.0, 200.0), (240.0, 300.0)]);
}
#[test]
fn gears_and_funnel_match_reference_points() {
// Square gear6: body radius 105, top tooth tip 3.5 inside the frame, tip half-width ~16.3.
on("gear6", 300.0, 300.0, &[], &[(150.0, 3.5), (133.7, 3.5), (166.3, 3.5), (45.0, 150.0)]);
on("gear9", 300.0, 300.0, &[], &[(150.0, 1.0), (30.0, 150.0)]);
on("gear6", 480.0, 300.0, &[], &[(240.0, 3.5), (201.0, 3.5)]);
on("funnel", 480.0, 300.0, &[], &[(240.0, 0.0), (0.0, 75.0), (240.0, 15.0), (15.0, 75.0), (240.0, 300.0), (60.5, 150.0)]);
let g = build("funnel", 480.0, 300.0, &[]).unwrap_or_else(|| panic!("funnel"));
assert!(g.paths[0].even_odd);
}
#[test]
fn circular_arrows_match_reference_points() {
on("circularArrow", 300.0, 300.0, &[], &[(19.0, 150.0), (56.5, 150.0), (150.0, 19.0), (262.4, 150.0), (218.6, 113.4), (293.6, 113.4)]);
on(
"circularArrow",
300.0,
300.0,
&[20000.0, 1800000.0, 18000000.0, 5400000.0, 20000.0],
&[(150.0, 270.0), (150.0, 210.0), (247.0, 42.1), (143.0, 102.1), (227.9, 105.0)],
);
on("leftCircularArrow", 300.0, 300.0, &[], &[(37.6, 150.0), (262.4, 150.0), (150.0, 281.0)]);
on("leftRightCircularArrow", 300.0, 300.0, &[], &[(262.4, 150.0), (37.6, 150.0), (150.0, 19.0)]);
// The band never grows past twice the head's half-width.
on("circularArrow", 300.0, 300.0, &[25000.0, 1142319.0, 20457681.0, 10800000.0, 5000.0], &[(0.0, 150.0), (30.0, 150.0)]);
}
}
+773
View File
@@ -0,0 +1,773 @@
//! WordArt text warps (DrawingML `a:bodyPr/a:prstTxWarp`).
//!
//! A warp preset is a list of bands laid over the shape's text rectangle (insets are ignored).
//! Text lines are shared out over the bands in order (the first ⌈n/k⌉ lines to the first band
//! and so on). A band either
//! - stretches its lines between a top and a bottom guide ([`Band::Fill`]): the ink box of the
//! band's lines fills the band vertically (a band whose ink is shorter than the tallest band's
//! keeps the same scale, centred), and horizontally the text is scaled so that the
//! widest band is filled along the mean length of its guides (one scale for the whole text), or
//! - sets its lines at their natural size along one guide ([`Band::Follow`], arches and circles):
//! glyphs keep their size, are placed by arc length (aligned as in the paragraph, the whole
//! guide being the line's width: insets don't count) and stand on
//! the guide's left normal; lines further from the guide stack outwards. Lines that are longer
//! than the guide are shrunk to fit.
//!
//! Positions along a guide are by arc length; a fill band maps the same fraction of its top and
//! bottom guide's length and interpolates between them. The guide curves were derived from
//! observation: synthetic decks of `I` bars were drawn with PowerPoint and every curve fitted to
//! the measured bar ends (most fit within a point on a 420 × 200 pt box).
use kurbo::{Arc, BezPath, PathEl, Point, Rect, Vec2};
/// Flattening tolerance for guides, in points.
const TOL: f64 = 0.02;
/// A guide curve flattened to a polyline with cumulative arc lengths.
#[derive(Clone, Debug, Default)]
pub struct Guide {
pts: Vec<Point>,
cum: Vec<f64>,
closed: bool,
}
impl Guide {
fn from_points(raw: impl IntoIterator<Item = Point>, closed: bool) -> Guide {
let mut pts: Vec<Point> = Vec::new();
let mut cum: Vec<f64> = Vec::new();
for p in raw {
if !(p.x.is_finite() && p.y.is_finite()) {
continue;
}
match pts.last() {
Some(q) => {
let d = (p - *q).hypot();
if d > 1e-9 {
cum.push(cum.last().copied().unwrap_or(0.0) + d);
pts.push(p);
}
}
None => {
pts.push(p);
cum.push(0.0);
}
}
if pts.len() > 100_000 {
break;
}
}
Guide { pts, cum, closed }
}
/// A guide through `path` (any curves), flattened.
pub fn from_path(path: &BezPath, closed: bool) -> Guide {
let mut pts = Vec::new();
kurbo::flatten(path.iter(), TOL, |el| match el {
PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p),
_ => {}
});
Guide::from_points(pts, closed)
}
/// Total arc length.
pub fn length(&self) -> f64 {
self.cum.last().copied().unwrap_or(0.0)
}
/// Whether the guide is a closed loop (positions past its end wrap around).
pub fn is_closed(&self) -> bool {
self.closed
}
/// Point and unit tangent at arc length `s`. Open guides continue along their end tangents
/// past either end; closed guides wrap.
pub fn at(&self, s: f64) -> (Point, Vec2) {
let len = self.length();
let (Some(&first), Some(&last)) = (self.pts.first(), self.pts.last()) else {
return (Point::ZERO, Vec2::new(1.0, 0.0));
};
if self.pts.len() < 2 || len <= 1e-9 {
return (first, Vec2::new(1.0, 0.0));
}
let s = if s.is_finite() { s } else { 0.0 };
let s = if self.closed { s.rem_euclid(len) } else { s };
let seg = |i: usize| -> (Point, Point, f64) {
let a = self.pts.get(i).copied().unwrap_or(first);
let b = self.pts.get(i + 1).copied().unwrap_or(last);
(a, b, self.cum.get(i).copied().unwrap_or(0.0))
};
let n = self.pts.len();
let i = if s <= 0.0 {
0
} else if s >= len {
n - 2
} else {
self.cum.partition_point(|c| *c <= s).saturating_sub(1).min(n - 2)
};
let (a, b, c0) = seg(i);
let d = b - a;
let l = d.hypot();
let t = if l > 1e-12 { d / l } else { Vec2::new(1.0, 0.0) };
(a + t * (s - c0), t)
}
/// The parallel curve `d` points away on the left of the direction of travel (above a
/// guide running left to right).
pub fn offset(&self, d: f64) -> Guide {
let n = self.pts.len();
if n < 2 || !d.is_finite() || d == 0.0 {
return self.clone();
}
let unit = |i: usize| -> Vec2 {
let (a, b) = (self.pts.get(i).copied().unwrap_or(Point::ZERO), self.pts.get(i + 1).copied().unwrap_or(Point::ZERO));
let v = b - a;
let l = v.hypot();
if l > 1e-12 { v / l } else { Vec2::ZERO }
};
let pts = (0..n).map(|i| {
let before = if i > 0 {
unit(i - 1)
} else if self.closed {
unit(n - 2)
} else {
Vec2::ZERO
};
let after = if i + 1 < n {
unit(i)
} else if self.closed {
unit(0)
} else {
Vec2::ZERO
};
let t = before + after;
let l = t.hypot();
let t = if l > 1e-12 { t / l } else { Vec2::new(1.0, 0.0) };
self.pts.get(i).copied().unwrap_or(Point::ZERO) + Vec2::new(t.y, -t.x) * d
});
Guide::from_points(pts.collect::<Vec<_>>(), self.closed)
}
/// The point at fraction `t` of the length.
pub fn at_frac(&self, t: f64) -> Point {
self.at(t * self.length()).0
}
}
/// Which line of a follow band sits on its guide.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineAnchor {
/// The last line; earlier lines stack outwards (arch up, circle).
Last,
/// The first line; later lines stack below it (arch down).
First,
/// The middle of the band's lines.
Middle,
}
#[derive(Clone, Debug)]
pub enum Band {
/// Text stretched between two guides.
Fill { top: Guide, bottom: Guide },
/// Text at its natural size along a guide.
Follow { path: Guide, anchor: LineAnchor },
}
impl Band {
/// The length text is measured against: the mean of a fill band's guides.
pub fn length(&self) -> f64 {
match self {
Band::Fill { top, bottom } => (top.length() + bottom.length()) / 2.0,
Band::Follow { path, .. } => path.length(),
}
}
}
/// A resolved warp: its bands in shape-local points.
#[derive(Clone, Debug)]
pub struct Warp {
pub bands: Vec<Band>,
}
/// Presets whose text follows a guide at its natural size (no wrapping).
pub fn is_follow(name: &str) -> bool {
matches!(name, "textArchUp" | "textArchDown" | "textCircle" | "textButton")
}
/// Default adjust values of a warp preset (`adj`, or `adj1`, `adj2`); empty for unknown presets.
pub fn adjust_defaults(name: &str) -> &'static [f64] {
match name {
"textPlain" => &[50_000.0],
"textStop" => &[25_000.0],
"textTriangle" | "textTriangleInverted" => &[50_000.0],
"textChevron" => &[25_000.0],
"textChevronInverted" => &[75_000.0],
"textRingInside" | "textRingOutside" => &[60_000.0],
"textArchUp" | "textCircle" | "textButton" => &[10_800_000.0],
"textArchDown" => &[0.0],
"textArchUpPour" | "textCirclePour" | "textButtonPour" => &[10_800_000.0, 50_000.0],
"textArchDownPour" => &[0.0, 25_000.0],
"textCurveUp" | "textCurveDown" => &[45_977.0],
"textCanUp" => &[85_000.0],
"textCanDown" => &[14_286.0],
"textWave1" | "textWave2" => &[12_500.0, 0.0],
"textDoubleWave1" | "textWave4" => &[6_250.0, 0.0],
"textInflate" | "textDeflate" => &[18_750.0],
"textInflateBottom" => &[60_000.0],
"textDeflateBottom" | "textDeflateTop" => &[50_000.0],
"textInflateTop" => &[40_000.0],
"textDeflateInflate" => &[35_000.0],
"textDeflateInflateDeflate" => &[25_000.0],
"textFadeRight" | "textFadeLeft" | "textFadeUp" | "textFadeDown" => &[33_333.0],
"textSlantUp" => &[55_556.0],
"textSlantDown" => &[44_444.0],
"textCascadeUp" | "textCascadeDown" => &[44_444.0],
_ => &[],
}
}
/// Number of adjust values a warp preset takes.
pub fn adjust_count(name: &str) -> usize {
adjust_defaults(name).len()
}
/// Whether `name` is a warp preset DeckCraft draws.
pub fn is_supported(name: &str) -> bool {
!adjust_defaults(name).is_empty()
}
/// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle).
#[derive(Clone, Copy, Debug)]
enum Seg {
M(f64, f64),
L(f64, f64),
Q(f64, f64, f64, f64),
C(f64, f64, f64, f64, f64, f64),
/// Elliptical arc: centre, radii, start and sweep in degrees (y down: positive = clockwise).
A(f64, f64, f64, f64, f64, f64),
}
enum Spec {
Fill(Vec<Seg>, Vec<Seg>),
Follow(Vec<Seg>, LineAnchor),
}
struct Adj<'a> {
raw: &'a [f64],
defaults: &'static [f64],
}
impl Adj<'_> {
fn raw(&self, i: usize) -> f64 {
self.raw.get(i).copied().filter(|v| v.is_finite()).or_else(|| self.defaults.get(i).copied()).unwrap_or(0.0)
}
/// Adjust `i` as a fraction (1/100 000), pinned to `lo..=hi` (fractions).
fn frac(&self, i: usize, lo: f64, hi: f64) -> f64 {
(self.raw(i) / 100_000.0).clamp(lo, hi)
}
/// Adjust `i` as an angle in degrees (1/60 000), in 0..360.
fn angle(&self, i: usize) -> f64 {
(self.raw(i) / 60_000.0).rem_euclid(360.0)
}
}
fn line(pts: &[(f64, f64)]) -> Vec<Seg> {
pts.iter().enumerate().map(|(i, &(x, y))| if i == 0 { Seg::M(x, y) } else { Seg::L(x, y) }).collect()
}
/// Quadratic from (0, y0) through control (0.5, c) to (1, y0).
fn quad(y0: f64, c: f64) -> Vec<Seg> {
vec![Seg::M(0.0, y0), Seg::Q(0.5, c, 1.0, y0)]
}
/// A sweep in (0, 360]: zero means a full turn.
fn turn(deg: f64) -> f64 {
let d = deg.rem_euclid(360.0);
if d < 1e-9 { 360.0 } else { d }
}
/// Arc of the ellipse inscribed in the rectangle, scaled by `s` about its centre.
fn ellipse(s: f64, start: f64, sweep: f64) -> Vec<Seg> {
vec![Seg::A(0.5, 0.5, 0.5 * s, 0.5 * s, start, sweep)]
}
/// The arch-up arc for adjust angle `a`: symmetric about the top (270°), clockwise.
fn arch_up(a: f64) -> (f64, f64) {
(a, turn(2.0 * (270.0 - a)))
}
/// The arch-down arc for adjust angle `a`: symmetric about the bottom (90°), counter-clockwise.
fn arch_down(a: f64) -> (f64, f64) {
(180.0 - a, -turn(180.0 - 2.0 * a))
}
/// The circle arc for adjust angle `a`: from `a` clockwise, symmetric about 0°.
fn circle(a: f64) -> (f64, f64) {
(a, turn(2.0 * (360.0 - a)))
}
/// One cubic wave period from x0 to x1 at height y: up first when `k` > 0 (y decreases).
fn wave(out: &mut Vec<Seg>, x0: f64, x1: f64, y: f64, k: f64) {
let l = x1 - x0;
out.push(Seg::C(x0 + l / 3.0, y - k, x0 + 2.0 * l / 3.0, y + k, x1, y));
}
/// Wave guides: `periods` cubic waves over the width, shifted apart by `shift` (fraction).
fn waves(a: f64, shift: f64, periods: usize, up_first: bool) -> Spec {
let d = 2.0 * shift;
let (top_x, bot_x) = if d >= 0.0 { ((0.0, 1.0 - d), (d, 1.0)) } else { ((-d, 1.0), (0.0, 1.0 + d)) };
let k = if up_first { a * 10.0 / 3.0 } else { -a * 10.0 / 3.0 };
let guide = |(x0, x1): (f64, f64), y: f64| {
let mut v = vec![Seg::M(x0, y)];
let n = periods.max(1) as f64;
for i in 0..periods.max(1) {
let f = i as f64;
wave(&mut v, x0 + (x1 - x0) * f / n, x0 + (x1 - x0) * (f + 1.0) / n, y, k);
}
v
};
Spec::Fill(guide(top_x, a), guide(bot_x, 1.0 - a))
}
fn spec(name: &str, a: &Adj) -> Option<Vec<Spec>> {
use Seg::*;
let fill = |t: Vec<Seg>, b: Vec<Seg>| vec![Spec::Fill(t, b)];
let top = || line(&[(0.0, 0.0), (1.0, 0.0)]);
let bottom = || line(&[(0.0, 1.0), (1.0, 1.0)]);
Some(match name {
"textPlain" => {
let d = 2.0 * (a.frac(0, 0.3, 0.7) - 0.5);
if d < 0.0 {
fill(line(&[(-d, 0.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0 + d, 1.0)]))
} else {
fill(line(&[(0.0, 0.0), (1.0 - d, 0.0)]), line(&[(d, 1.0), (1.0, 1.0)]))
}
}
"textStop" => {
let y = a.frac(0, 0.14286, 0.5);
let t = 1.0 / 3.0;
fill(line(&[(0.0, y), (t, 0.0), (1.0 - t, 0.0), (1.0, y)]), line(&[(0.0, 1.0 - y), (t, 1.0), (1.0 - t, 1.0), (1.0, 1.0 - y)]))
}
"textTriangle" => {
let y = a.frac(0, 0.0, 1.0);
fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), bottom())
}
"textTriangleInverted" => {
let y = a.frac(0, 0.0, 1.0);
fill(top(), line(&[(0.0, y), (0.5, 1.0), (1.0, y)]))
}
"textChevron" => {
let y = a.frac(0, 0.0, 0.5);
fill(line(&[(0.0, y), (0.5, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (0.5, 1.0 - y), (1.0, 1.0)]))
}
"textChevronInverted" => {
let y = a.frac(0, 0.5, 1.0);
fill(line(&[(0.0, 0.0), (0.5, 1.0 - y), (1.0, 0.0)]), line(&[(0.0, y), (0.5, 1.0), (1.0, y)]))
}
"textFadeRight" => {
let y = a.frac(0, 0.0, 0.49999);
fill(line(&[(0.0, 0.0), (1.0, y)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)]))
}
"textFadeLeft" => {
let y = a.frac(0, 0.0, 0.49999);
fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0 - y), (1.0, 1.0)]))
}
"textFadeUp" => {
let x = a.frac(0, 0.0, 0.49999);
fill(line(&[(x, 0.0), (1.0 - x, 0.0)]), bottom())
}
"textFadeDown" => {
let x = a.frac(0, 0.0, 0.49999);
fill(top(), line(&[(x, 1.0), (1.0 - x, 1.0)]))
}
"textSlantUp" => {
let y = a.frac(0, 0.44444, 1.0);
fill(line(&[(0.0, y), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, 1.0 - y)]))
}
"textSlantDown" => {
let y = a.frac(0, 0.0, 0.55556);
fill(line(&[(0.0, 0.0), (1.0, 1.0 - y)]), line(&[(0.0, y), (1.0, 1.0)]))
}
"textCascadeUp" => {
let y = a.frac(0, 0.0, 1.0);
fill(line(&[(0.0, (1.0 - y) / 4.0), (1.0, 0.0)]), line(&[(0.0, 1.0), (1.0, y)]))
}
"textCascadeDown" => {
let y = a.frac(0, 0.0, 1.0);
fill(line(&[(0.0, 0.0), (1.0, (1.0 - y) / 4.0)]), line(&[(0.0, y), (1.0, 1.0)]))
}
"textInflate" => {
let y = a.frac(0, 0.0, 0.2);
fill(quad(y, -y), quad(1.0 - y, 1.0 + y))
}
"textDeflate" => {
let y = a.frac(0, 0.0, 0.375);
fill(quad(0.0, 2.0 * y), quad(1.0, 1.0 - 2.0 * y))
}
"textInflateBottom" => {
let y = a.frac(0, 0.6, 1.0);
fill(top(), quad(y, 1.0))
}
"textDeflateBottom" => {
let y = a.frac(0, 0.0625, 1.0);
fill(top(), quad(1.0, 2.0 * y - 1.0))
}
"textInflateTop" => {
let y = a.frac(0, 0.0, 0.5);
fill(quad(y, 0.0), bottom())
}
"textDeflateTop" => {
let y = a.frac(0, 0.0, 0.9375);
fill(quad(0.0, 2.0 * y), bottom())
}
"textDeflateInflate" => {
// The bands are split by a curve from mid-height up to `y`, with a gap of a tenth of
// a band's height.
let y = a.frac(0, 0.05, 0.95);
let g = 0.05;
vec![Spec::Fill(top(), quad(0.5 - g, 2.0 * y - 0.5 - g)), Spec::Fill(quad(0.5 + g, 2.0 * y - 0.5 + g), bottom())]
}
"textDeflateInflateDeflate" => {
let y = a.frac(0, 0.01, 1.0 / 3.0);
let g = 1.0 / 30.0;
let (t1, t2) = (1.0 / 3.0, 2.0 / 3.0);
let (c1, c2) = (2.0 * y - t1, 2.0 * (1.0 - y) - t2);
vec![
Spec::Fill(top(), quad(t1 - g, c1 - g)),
Spec::Fill(quad(t1 + g, c1 + g), quad(t2 - g, c2 - g)),
Spec::Fill(quad(t2 + g, c2 + g), bottom()),
]
}
"textCurveUp" | "textCurveDown" => {
let y = a.frac(0, 0.0, 0.56818);
let b = vec![M(0.0, 1.0 - 3.0 * y / 8.0), C(1.0 / 3.0, 1.0 + y / 8.0, 2.0 / 3.0, 1.0 + y / 8.0, 1.0, 1.0 - 3.0 * y / 8.0)];
let t = if name == "textCurveUp" {
vec![M(0.0, y), C(1.0 / 3.0, 1.25 * y, 2.0 / 3.0, 0.75 * y, 1.0, 0.0)]
} else {
vec![M(0.0, 0.0), C(1.0 / 3.0, 0.75 * y, 2.0 / 3.0, 1.25 * y, 1.0, y)]
};
fill(t, b)
}
"textCanUp" => {
let r = 1.0 - a.frac(0, 0.5, 1.0);
fill(vec![A(0.5, r, 0.5, r, 180.0, 180.0)], vec![A(0.5, 1.0, 0.5, r, 180.0, 180.0)])
}
"textCanDown" => {
let r = a.frac(0, 0.0, 0.5);
fill(vec![A(0.5, 0.0, 0.5, r, 180.0, -180.0)], vec![A(0.5, 1.0 - r, 0.5, r, 180.0, -180.0)])
}
"textRingInside" | "textRingOutside" => {
let h = a.frac(0, 0.5, 0.99);
let sweep = if name == "textRingInside" { 360.0 } else { -360.0 };
fill(vec![A(0.5, h / 2.0, 0.5, h / 2.0, 180.0, sweep)], vec![A(0.5, 1.0 - h / 2.0, 0.5, h / 2.0, 180.0, sweep)])
}
"textWave1" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, true)],
"textWave2" => vec![waves(a.frac(0, 0.0, 0.2), a.frac(1, -0.1, 0.1), 1, false)],
"textDoubleWave1" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, true)],
"textWave4" => vec![waves(a.frac(0, 0.0, 0.125), a.frac(1, -0.1, 0.1), 2, false)],
"textArchUp" => {
let (s, w) = arch_up(a.angle(0));
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)]
}
"textArchDown" => {
let (s, w) = arch_down(a.angle(0));
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::First)]
}
"textCircle" => {
let (s, w) = circle(a.angle(0));
vec![Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last)]
}
"textButton" => {
let t = a.angle(0);
let (s, w) = arch_up(t);
let half = 0.5 * t.to_radians().cos().abs();
vec![
Spec::Follow(ellipse(1.0, s, w), LineAnchor::Last),
Spec::Follow(line(&[(0.5 - half, 0.5), (0.5 + half, 0.5)]), LineAnchor::Middle),
Spec::Follow(ellipse(1.0, 360.0 - s, -w), LineAnchor::First),
]
}
"textArchUpPour" => {
let (s, w) = arch_up(a.angle(0));
let r = a.frac(1, 0.0, 0.99);
fill(ellipse(1.0, s, w), ellipse(r, s, w))
}
"textArchDownPour" => {
let (s, w) = arch_down(a.angle(0));
let r = a.frac(1, 0.0, 0.99);
fill(ellipse(r, s, w), ellipse(1.0, s, w))
}
"textCirclePour" => {
let (s, w) = circle(a.angle(0));
let r = a.frac(1, 0.0, 0.99);
fill(ellipse(1.0, s, w), ellipse(r, s, w))
}
"textButtonPour" => {
let (s, w) = arch_up(a.angle(0));
let r = a.frac(1, 0.0, 0.99);
// The middle band runs between chords of the inner ellipse halfway to its top and
// bottom.
let (dy, half) = (0.25 * r, 0.25 * r * 3f64.sqrt());
vec![
Spec::Fill(ellipse(1.0, s, w), ellipse(r, s, w)),
Spec::Fill(line(&[(0.5 - half, 0.5 - dy), (0.5 + half, 0.5 - dy)]), line(&[(0.5 - half, 0.5 + dy), (0.5 + half, 0.5 + dy)])),
Spec::Fill(ellipse(r, 360.0 - s, -w), ellipse(1.0, 360.0 - s, -w)),
]
}
_ => return None,
})
}
/// Build a guide from unit-space segments mapped onto `r`.
fn guide(segs: &[Seg], r: Rect) -> Guide {
let mut p = BezPath::new();
let mut started = false;
let mut closed = false;
for s in segs {
match *s {
Seg::M(x, y) => {
p.move_to((x, y));
started = true;
}
Seg::L(x, y) => {
if started {
p.line_to((x, y))
} else {
p.move_to((x, y))
}
started = true;
}
Seg::Q(cx, cy, x, y) => {
if started {
p.quad_to((cx, cy), (x, y));
}
}
Seg::C(x1, y1, x2, y2, x, y) => {
if started {
p.curve_to((x1, y1), (x2, y2), (x, y));
}
}
Seg::A(cx, cy, rx, ry, start, sweep) => {
let arc = Arc::new((cx, cy), (rx.max(0.0), ry.max(0.0)), start.to_radians(), sweep.to_radians(), 0.0);
let s0 = Point::new(cx + rx * start.to_radians().cos(), cy + ry * start.to_radians().sin());
if started {
p.line_to(s0);
} else {
p.move_to(s0);
}
started = true;
for el in arc.append_iter(1e-5) {
p.push(el);
}
closed = sweep.abs() >= 359.999;
}
}
}
let m = kurbo::Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]);
p.apply_affine(m);
Guide::from_path(&p, closed)
}
/// The warp `name` with adjust values `adj` (raw guide values; missing ones take the defaults)
/// over text rectangle `rect`. `None` for unknown presets, `textNoShape` or a non-finite rect.
pub fn warp(name: &str, adj: &[f64], rect: Rect) -> Option<Warp> {
if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) {
return None;
}
let r = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1));
let a = Adj { raw: adj, defaults: adjust_defaults(name) };
let specs = spec(name, &a)?;
let bands = specs
.into_iter()
.map(|s| match s {
Spec::Fill(t, b) => Band::Fill { top: guide(&t, r), bottom: guide(&b, r) },
Spec::Follow(p, anchor) => Band::Follow { path: guide(&p, r), anchor },
})
.collect();
Some(Warp { bands })
}
/// The band line `line` of `lines` goes to when there are `bands` bands: the first ⌈n/k⌉ lines
/// share the first band, and so on.
pub fn band_of_line(line: usize, lines: usize, bands: usize) -> usize {
if bands <= 1 || lines == 0 {
return 0;
}
// Band b holds lines ceil(b·n/k) .. ceil((b+1)·n/k).
(0..bands).rev().find(|&b| (b * lines).div_ceil(bands) <= line).unwrap_or(0)
}
/// One laid-out line as the warp sees it (layout coordinates, points).
#[derive(Clone, Copy, Debug)]
pub struct WarpLine {
/// Ink box of the line's glyphs; `None` for a line without ink.
pub ink: Option<Rect>,
/// Advance extent of the line (left, right).
pub extent: (f64, f64),
/// Reference height for follow bands: the y that sits on the guide.
pub ref_y: f64,
}
#[derive(Clone, Copy, Debug)]
enum LineMap {
Fill {
band: usize,
cx: f64,
k: f64,
len: f64,
y0: f64,
y1: f64,
},
/// `guide`: the line's own parallel guide in [`Mapper::offsets`], if it is off the band's.
Follow {
band: usize,
guide: Option<usize>,
e0: f64,
s0: f64,
k: f64,
ref_y: f64,
},
}
/// Maps points of laid-out lines onto a warp.
#[derive(Clone, Debug)]
pub struct Mapper<'a> {
warp: &'a Warp,
lines: Vec<LineMap>,
offsets: Vec<Guide>,
zoom: f64,
}
impl Warp {
/// Plan the mapping of `lines`, laid out in the inner text box `inner` (the line's place in
/// it gives its alignment along follow guides; the insets themselves don't shorten a guide).
pub fn mapper(&self, lines: &[WarpLine], inner: Rect) -> Mapper<'_> {
let nb = self.bands.len().max(1);
let n = lines.len();
let band_of: Vec<usize> = (0..n).map(|i| band_of_line(i, n, nb).min(nb - 1)).collect();
// Ink of the whole text, for the shared horizontal centre and scale of fill bands.
let ink_all = lines.iter().filter_map(|l| l.ink).reduce(|a, b| a.union(b));
let cx = ink_all.map(|r| r.center().x).unwrap_or(0.0);
let mut k_fill = f64::INFINITY;
let mut zoom: f64 = 1.0;
let mut band_y: Vec<Option<(f64, f64)>> = vec![None; nb];
for (i, l) in lines.iter().enumerate() {
let Some(r) = l.ink else { continue };
let b = band_of.get(i).copied().unwrap_or(0);
if let Some(slot) = band_y.get_mut(b) {
*slot = Some(match *slot {
Some((y0, y1)) => (y0.min(r.y0), y1.max(r.y1)),
None => (r.y0, r.y1),
});
}
}
// Every fill band maps a box as tall as the tallest band's ink, centred on its own ink, so
// a band with fewer lines keeps them at the same size, centred between its guides.
let tallest = band_y.iter().flatten().map(|(y0, y1)| y1 - y0).fold(0.0, f64::max);
let band_y: Vec<Option<(f64, f64)>> =
band_y.iter().map(|r| r.map(|(y0, y1)| ((y0 + y1 - tallest) / 2.0, (y0 + y1 + tallest) / 2.0))).collect();
for (b, band) in self.bands.iter().enumerate() {
if let Band::Fill { top, bottom } = band {
let half = lines
.iter()
.enumerate()
.filter(|(i, _)| band_of.get(*i) == Some(&b))
.filter_map(|(_, l)| l.ink)
.map(|r| (r.x0 - cx).abs().max((r.x1 - cx).abs()))
.fold(0.0, f64::max);
if half > 1e-9 {
k_fill = k_fill.min(band.length() / (2.0 * half));
}
if let Some(Some((y0, y1))) = band_y.get(b) {
let thick = [0.0, 0.25, 0.5, 0.75, 1.0].iter().map(|&u| (top.at_frac(u) - bottom.at_frac(u)).hypot()).fold(0.0, f64::max);
if y1 - y0 > 1e-9 {
zoom = zoom.max(thick / (y1 - y0));
}
}
}
}
if !k_fill.is_finite() {
k_fill = 1.0;
}
zoom = zoom.max(k_fill);
let mut maps = Vec::with_capacity(n);
let mut offsets = Vec::new();
for (i, l) in lines.iter().enumerate() {
let b = band_of.get(i).copied().unwrap_or(0);
let in_band = |j: &usize| band_of.get(*j) == Some(&b);
match self.bands.get(b) {
Some(Band::Fill { .. }) | None => {
let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0));
let len = self.bands.get(b).map(Band::length).unwrap_or(0.0);
maps.push(LineMap::Fill { band: b, cx, k: k_fill, len, y0, y1 });
}
Some(Band::Follow { path, anchor }) => {
let len = path.length();
let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max);
let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 };
let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect();
let ref_y = match anchor {
LineAnchor::Last => members.last().map(|l| l.ref_y),
LineAnchor::First => members.first().map(|l| l.ref_y),
LineAnchor::Middle => match (members.first(), members.last()) {
(Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0),
_ => None,
},
}
.unwrap_or(l.ref_y);
// Lines off the guide run on a parallel curve at their natural length.
let d = (ref_y - l.ref_y) * k;
let (guide, len) = if d.abs() > 1e-6 && d.is_finite() && offsets.len() < 4096 {
let g = path.offset(d);
let len = g.length();
offsets.push(g);
(Some(offsets.len() - 1), len)
} else {
(None, len)
};
let (e0, e1) = l.extent;
let w = (e1 - e0).max(0.0) * k;
let (gl, gr) = ((e0 - inner.x0).max(0.0), (inner.x1 - e1).max(0.0));
let f = if gl + gr > 1e-9 { gl / (gl + gr) } else { 0.5 };
let s0 = f * (len - w);
maps.push(LineMap::Follow { band: b, guide, e0, s0, k, ref_y: l.ref_y });
}
}
}
Mapper { warp: self, lines: maps, offsets, zoom: if zoom.is_finite() { zoom.clamp(1.0, 1.0e4) } else { 1.0 } }
}
}
impl Mapper<'_> {
/// How much the warp magnifies text at most (for flattening tolerances), ≥ 1.
pub fn zoom(&self) -> f64 {
self.zoom
}
/// Map point `p` of line `line` (layout coordinates) onto the warp.
pub fn map(&self, line: usize, p: Point) -> Point {
let out = match self.lines.get(line) {
Some(&LineMap::Fill { band, cx, k, len, y0, y1 }) => {
let Some(Band::Fill { top, bottom }) = self.warp.bands.get(band) else { return p };
let u = if len > 1e-9 { 0.5 + (p.x - cx) * k / len } else { 0.5 };
let v = if y1 - y0 > 1e-9 { (p.y - y0) / (y1 - y0) } else { 0.5 };
let (t, b) = (top.at_frac(u), bottom.at_frac(u));
t.lerp(b, v)
}
Some(&LineMap::Follow { band, guide, e0, s0, k, ref_y }) => {
let path = match (guide.and_then(|g| self.offsets.get(g)), self.warp.bands.get(band)) {
(Some(g), _) => g,
(None, Some(Band::Follow { path, .. })) => path,
_ => return p,
};
let s = s0 + (p.x - e0) * k;
let d = (ref_y - p.y) * k;
let (q, t) = path.at(s);
q + Vec2::new(t.y, -t.x) * d
}
None => p,
};
if out.x.is_finite() && out.y.is_finite() { out } else { p }
}
}
#[cfg(test)]
mod tests;
+309
View File
@@ -0,0 +1,309 @@
use super::*;
const ALL: &[&str] = &[
"textPlain",
"textStop",
"textTriangle",
"textTriangleInverted",
"textChevron",
"textChevronInverted",
"textRingInside",
"textRingOutside",
"textArchUp",
"textArchDown",
"textCircle",
"textButton",
"textArchUpPour",
"textArchDownPour",
"textCirclePour",
"textButtonPour",
"textCurveUp",
"textCurveDown",
"textCanUp",
"textCanDown",
"textWave1",
"textWave2",
"textDoubleWave1",
"textWave4",
"textInflate",
"textDeflate",
"textInflateBottom",
"textDeflateBottom",
"textInflateTop",
"textDeflateTop",
"textDeflateInflate",
"textDeflateInflateDeflate",
"textFadeRight",
"textFadeLeft",
"textFadeUp",
"textFadeDown",
"textSlantUp",
"textSlantDown",
"textCascadeUp",
"textCascadeDown",
];
const W: f64 = 420.0;
const H: f64 = 200.0;
fn rect() -> Rect {
Rect::new(0.0, 0.0, W, H)
}
/// One line whose ink box is `ink`.
fn one_line(ink: Rect) -> Vec<WarpLine> {
vec![WarpLine { ink: Some(ink), extent: (ink.x0, ink.x1), ref_y: ink.y1 }]
}
/// Where the unit-square point (u, v) of a single line's ink box lands, in unit coordinates.
fn land(name: &str, adj: &[f64], u: f64, v: f64) -> (f64, f64) {
let w = warp(name, adj, rect()).unwrap();
let ink = Rect::new(100.0, 50.0, 300.0, 90.0);
let lines = one_line(ink);
let m = w.mapper(&lines, rect().inset(-5.0));
let p = m.map(0, Point::new(ink.x0 + u * ink.width(), ink.y0 + v * ink.height()));
(p.x / W, p.y / H)
}
fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool {
(a.0 - b.0).abs() <= tol && (a.1 - b.1).abs() <= tol
}
#[track_caller]
fn corners(name: &str, adj: &[f64], tl: (f64, f64), tr: (f64, f64), bl: (f64, f64), br: (f64, f64)) {
for ((u, v), want) in [((0.0, 0.0), tl), ((1.0, 0.0), tr), ((0.0, 1.0), bl), ((1.0, 1.0), br)] {
let got = land(name, adj, u, v);
assert!(close(got, want, 0.004), "{name} corner ({u},{v}): got {got:?}, want {want:?}");
}
}
#[test]
fn every_preset_builds_finite_guides() {
for name in ALL {
assert!(is_supported(name), "{name}");
let w = warp(name, &[], rect()).unwrap_or_else(|| panic!("{name}"));
assert!(!w.bands.is_empty(), "{name}");
for b in &w.bands {
let l = b.length();
assert!(l.is_finite() && l > 10.0, "{name}: band length {l}");
}
assert_eq!(adjust_count(name), adjust_defaults(name).len());
}
assert!(warp("textNoShape", &[], rect()).is_none());
assert!(warp("bogus", &[], rect()).is_none());
assert!(!is_supported("textNoShape"));
}
#[test]
fn straight_presets_put_the_ink_corners_on_their_guides() {
corners("textPlain", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0));
// A smaller adjust shears the text: the top edge starts 40 % in, the bottom edge ends 60 % in.
corners("textPlain", &[30_000.0], (0.4, 0.0), (1.0, 0.0), (0.0, 1.0), (0.6, 1.0));
corners("textStop", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 0.75), (1.0, 0.75));
corners("textTriangle", &[], (0.0, 0.5), (1.0, 0.5), (0.0, 1.0), (1.0, 1.0));
corners("textTriangleInverted", &[25_000.0], (0.0, 0.0), (1.0, 0.0), (0.0, 0.25), (1.0, 0.25));
corners("textChevron", &[], (0.0, 0.25), (1.0, 0.25), (0.0, 1.0), (1.0, 1.0));
corners("textChevronInverted", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.75), (1.0, 0.75));
corners("textFadeRight", &[], (0.0, 0.0), (1.0, 0.33333), (0.0, 1.0), (1.0, 0.66667));
corners("textFadeLeft", &[], (0.0, 0.33333), (1.0, 0.0), (0.0, 0.66667), (1.0, 1.0));
corners("textFadeUp", &[], (0.33333, 0.0), (0.66667, 0.0), (0.0, 1.0), (1.0, 1.0));
corners("textFadeDown", &[], (0.0, 0.0), (1.0, 0.0), (0.33333, 1.0), (0.66667, 1.0));
corners("textSlantUp", &[], (0.0, 0.55556), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444));
corners("textSlantDown", &[], (0.0, 0.0), (1.0, 0.55556), (0.0, 0.44444), (1.0, 1.0));
corners("textCascadeUp", &[], (0.0, 0.13889), (1.0, 0.0), (0.0, 1.0), (1.0, 0.44444));
corners("textCascadeDown", &[], (0.0, 0.0), (1.0, 0.13889), (0.0, 0.44444), (1.0, 1.0));
}
#[test]
fn curved_presets_bend_where_powerpoint_bends_them() {
corners("textInflate", &[], (0.0, 0.1875), (1.0, 0.1875), (0.0, 0.8125), (1.0, 0.8125));
assert!(close(land("textInflate", &[], 0.5, 0.0), (0.5, 0.0), 0.004));
assert!(close(land("textInflate", &[], 0.5, 1.0), (0.5, 1.0), 0.004));
corners("textDeflate", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0));
assert!(close(land("textDeflate", &[], 0.5, 0.0), (0.5, 0.1875), 0.004));
corners("textInflateBottom", &[], (0.0, 0.0), (1.0, 0.0), (0.0, 0.6), (1.0, 0.6));
assert!(close(land("textInflateBottom", &[], 0.5, 1.0), (0.5, 0.8), 0.004));
assert!(close(land("textDeflateBottom", &[], 0.5, 1.0), (0.5, 0.5), 0.004));
assert!(close(land("textInflateTop", &[], 0.5, 0.0), (0.5, 0.2), 0.004));
assert!(close(land("textDeflateTop", &[], 0.5, 0.0), (0.5, 0.5), 0.004));
// Cans: half ellipses.
corners("textCanUp", &[], (0.0, 0.15), (1.0, 0.15), (0.0, 1.0), (1.0, 1.0));
assert!(close(land("textCanUp", &[], 0.5, 0.0), (0.5, 0.0), 0.004));
assert!(close(land("textCanUp", &[], 0.5, 1.0), (0.5, 0.85), 0.004));
assert!(close(land("textCanDown", &[], 0.5, 0.0), (0.5, 0.14286), 0.004));
// Curves and waves.
let a = 0.45977;
corners("textCurveUp", &[], (0.0, a), (1.0, 0.0), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0));
assert!(close(land("textCurveUp", &[], 0.5, 1.0), (0.5, 1.0), 0.004));
corners("textCurveDown", &[], (0.0, 0.0), (1.0, a), (0.0, 1.0 - 3.0 * a / 8.0), (1.0, 1.0 - 3.0 * a / 8.0));
corners("textWave1", &[], (0.0, 0.125), (1.0, 0.125), (0.0, 0.875), (1.0, 0.875));
// Wave 1 rises first: its top is near the box top a quarter of the way along.
assert!(land("textWave1", &[], 0.25, 0.0).1 < 0.02);
assert!(land("textWave2", &[], 0.25, 0.0).1 > 0.23);
// A horizontal shift moves the bottom guide right.
corners("textWave1", &[12_500.0, 5_000.0], (0.0, 0.125), (0.9, 0.125), (0.1, 0.875), (1.0, 0.875));
assert!(land("textDoubleWave1", &[], 0.125, 0.0).1 < 0.02);
assert!(land("textWave4", &[], 0.125, 0.0).1 > 0.1);
}
#[test]
fn arc_presets_follow_ellipses() {
// Pour arches: the outer arc from the left end over the top; the inner one is scaled by adj2.
corners("textArchUpPour", &[], (0.0, 0.5), (1.0, 0.5), (0.25, 0.5), (0.75, 0.5));
assert!(close(land("textArchUpPour", &[], 0.5, 0.0), (0.5, 0.0), 0.004));
assert!(close(land("textArchUpPour", &[], 0.5, 1.0), (0.5, 0.25), 0.004));
assert!(close(land("textArchUpPour", &[10_800_000.0, 30_000.0], 0.5, 1.0), (0.5, 0.35), 0.004));
assert!(close(land("textArchDownPour", &[], 0.5, 0.0), (0.5, 0.625), 0.004));
assert!(close(land("textArchDownPour", &[], 0.5, 1.0), (0.5, 1.0), 0.004));
// Circle pour: once round from the left; the middle of the text is at the right.
assert!(close(land("textCirclePour", &[], 0.5, 0.0), (1.0, 0.5), 0.004));
// Rings: the text goes round two stacked ellipses.
assert!(close(land("textRingInside", &[], 0.0, 0.0), (0.0, 0.3), 0.004));
assert!(close(land("textRingInside", &[], 0.0, 1.0), (0.0, 0.7), 0.004));
assert!(close(land("textRingInside", &[], 0.25, 0.0), (0.5, 0.0), 0.01));
assert!(close(land("textRingOutside", &[], 0.25, 1.0), (0.5, 1.0), 0.01));
}
#[test]
fn follow_presets_keep_text_size_and_stack_lines_outwards() {
let w = warp("textArchUp", &[], rect()).unwrap();
// A 40-wide centred line whose reference height is 80.
let line = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 88.0)), extent: (190.0, 230.0), ref_y: 80.0 };
let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6);
let m = w.mapper(&[line], inner);
// The middle of the reference line lands on the top of the ellipse; 10 pt above it lands
// 10 pt above the top.
let p = m.map(0, Point::new(210.0, 80.0));
assert!((p.x - 210.0).abs() < 0.05 && p.y.abs() < 0.05, "{p:?}");
let q = m.map(0, Point::new(210.0, 70.0));
assert!((q.x - 210.0).abs() < 0.15 && (q.y + 10.0).abs() < 0.05, "{q:?}");
// Natural size: 40 pt of text spans about 40 pt of arc.
let (a, b) = (m.map(0, Point::new(190.0, 80.0)), m.map(0, Point::new(230.0, 80.0)));
assert!(((b - a).hypot() - 40.0).abs() < 0.5);
// Left-aligned text starts at the very start of the arc: insets don't shorten it.
let left = WarpLine { ink: Some(Rect::new(7.2, 50.0, 47.2, 88.0)), extent: (7.2, 47.2), ref_y: 80.0 };
let m = w.mapper(&[left], inner);
let p = m.map(0, Point::new(7.2, 80.0));
assert!(p.x.abs() < 0.01 && (p.y - 100.0).abs() < 0.01, "{p:?}");
// Two lines on an arch up: the last sits on the arc, the first stacks outside it.
let l1 = WarpLine { ink: Some(Rect::new(190.0, 10.0, 230.0, 40.0)), extent: (190.0, 230.0), ref_y: 34.0 };
let l2 = WarpLine { ink: Some(Rect::new(190.0, 50.0, 230.0, 80.0)), extent: (190.0, 230.0), ref_y: 74.0 };
let m = w.mapper(&[l1, l2], inner);
assert!(m.map(1, Point::new(210.0, 74.0)).y.abs() < 0.05);
assert!((m.map(0, Point::new(210.0, 34.0)).y + 40.0).abs() < 0.05);
// Arch down: the first line sits on the bottom of the ellipse.
let w = warp("textArchDown", &[], rect()).unwrap();
let m = w.mapper(&[l1, l2], inner);
assert!((m.map(0, Point::new(210.0, 34.0)).y - H).abs() < 0.05);
assert!((m.map(1, Point::new(210.0, 74.0)).y - H - 40.0).abs() < 0.05);
// Text longer than the arc is shrunk to fit it.
let w = warp("textArchUp", &[], rect()).unwrap();
let long = WarpLine { ink: Some(Rect::new(-500.0, 50.0, 900.0, 88.0)), extent: (-500.0, 900.0), ref_y: 80.0 };
let m = w.mapper(&[long], inner);
let (a, b) = (m.map(0, Point::new(-500.0, 80.0)), m.map(0, Point::new(900.0, 80.0)));
assert!(a.x < 5.0 && b.x > W - 5.0 && (a.y - 100.0).abs() < 10.0 && (b.y - 100.0).abs() < 10.0, "{a:?} {b:?}");
}
#[test]
fn lines_share_bands_in_order() {
assert_eq!((0..2).map(|i| band_of_line(i, 2, 2)).collect::<Vec<_>>(), [0, 1]);
assert_eq!((0..3).map(|i| band_of_line(i, 3, 2)).collect::<Vec<_>>(), [0, 0, 1]);
assert_eq!((0..5).map(|i| band_of_line(i, 5, 2)).collect::<Vec<_>>(), [0, 0, 0, 1, 1]);
assert_eq!((0..5).map(|i| band_of_line(i, 5, 3)).collect::<Vec<_>>(), [0, 0, 1, 1, 2]);
assert_eq!((0..4).map(|i| band_of_line(i, 4, 3)).collect::<Vec<_>>(), [0, 0, 1, 2]);
assert_eq!((0..2).map(|i| band_of_line(i, 2, 3)).collect::<Vec<_>>(), [0, 1]);
assert_eq!(band_of_line(0, 1, 3), 0);
assert_eq!(band_of_line(7, 9, 1), 0);
assert_eq!(band_of_line(0, 0, 3), 0);
}
#[test]
fn deflate_inflate_maps_each_band_separately() {
let w = warp("textDeflateInflate", &[], rect()).unwrap();
assert_eq!(w.bands.len(), 2);
let l1 = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 40.0)), extent: (100.0, 300.0), ref_y: 34.0 };
let l2 = WarpLine { ink: Some(Rect::new(100.0, 50.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 74.0 };
let m = w.mapper(&[l1, l2], rect());
// Band 1 ends at 45 % height on the sides, band 2 starts at 55 %.
assert!((m.map(0, Point::new(100.0, 40.0)).y / H - 0.45).abs() < 0.004);
assert!((m.map(1, Point::new(100.0, 50.0)).y / H - 0.55).abs() < 0.004);
assert!((m.map(1, Point::new(300.0, 80.0)).y / H - 1.0).abs() < 0.004);
// A band with fewer lines keeps them at the size of the fuller band, centred in its band:
// two lines (ink 10..60) in band 1, one line (ink 70..80) in band 2 of three.
let a = WarpLine { ink: Some(Rect::new(100.0, 10.0, 300.0, 30.0)), extent: (100.0, 300.0), ref_y: 28.0 };
let b = WarpLine { ink: Some(Rect::new(100.0, 40.0, 300.0, 60.0)), extent: (100.0, 300.0), ref_y: 58.0 };
let c = WarpLine { ink: Some(Rect::new(100.0, 70.0, 300.0, 80.0)), extent: (100.0, 300.0), ref_y: 78.0 };
let m = w.mapper(&[a, b, c], rect());
let (top, bot) = (m.map(2, Point::new(200.0, 70.0)).y / H, m.map(2, Point::new(200.0, 80.0)).y / H);
// Band 2 runs from 0.4 to 1 at the centre; the line takes the middle fifth of it.
assert!((top - 0.64).abs() < 0.005 && (bot - 0.76).abs() < 0.005, "{top} {bot}");
// A shorter first line keeps its share of the width.
let short = WarpLine { ink: Some(Rect::new(150.0, 10.0, 250.0, 40.0)), extent: (150.0, 250.0), ref_y: 34.0 };
let m = w.mapper(&[short, l2], rect());
let p = m.map(0, Point::new(150.0, 10.0));
assert!((p.x / W - 0.25).abs() < 0.01, "{p:?}");
}
#[test]
fn guides_extrapolate_and_wrap() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((10.0, 0.0));
let g = Guide::from_path(&p, false);
assert!((g.length() - 10.0).abs() < 1e-9);
assert!((g.at(15.0).0.x - 15.0).abs() < 1e-9);
assert!((g.at(-5.0).0.x + 5.0).abs() < 1e-9);
assert!(g.at(f64::NAN).0.x.is_finite());
let w = warp("textCircle", &[], rect()).unwrap();
let Some(Band::Follow { path, .. }) = w.bands.first() else { panic!() };
assert!(path.is_closed());
let l = path.length();
let (a, b) = (path.at(5.0).0, path.at(l + 5.0).0);
assert!((a - b).hypot() < 1e-6);
let empty = Guide::default();
assert_eq!(empty.length(), 0.0);
assert!(empty.at(3.0).0.x.is_finite());
}
#[test]
fn hostile_adjusts_and_boxes_stay_finite() {
let adjs: &[&[f64]] =
&[&[], &[f64::NAN, f64::INFINITY], &[f64::NEG_INFINITY, f64::NAN], &[1e300, -1e300], &[-1e9, 1e9], &[0.0, 0.0], &[21_599_999.0, 100_000.0]];
let rects = [
Rect::new(0.0, 0.0, 0.0, 0.0),
Rect::new(10.0, 10.0, 10.0, 200.0),
Rect::new(0.0, 0.0, 300.0, 0.0),
Rect::new(100.0, 100.0, -50.0, -20.0),
Rect::new(0.0, 0.0, 1e7, 1e7),
];
let lines = [
WarpLine { ink: Some(Rect::new(0.0, 0.0, 0.0, 0.0)), extent: (0.0, 0.0), ref_y: 0.0 },
WarpLine { ink: None, extent: (5.0, 5.0), ref_y: 3.0 },
WarpLine { ink: Some(Rect::new(-1e6, -5.0, 1e6, 5.0)), extent: (-1e6, 1e6), ref_y: 4.0 },
];
for name in ALL {
for adj in adjs {
for r in rects {
let Some(w) = warp(name, adj, r) else { panic!("{name} {adj:?} {r:?}") };
for b in &w.bands {
assert!(b.length().is_finite());
}
let m = w.mapper(&lines, r);
assert!(m.zoom().is_finite() && m.zoom() >= 1.0);
let probes = [
(0, Point::ZERO),
(1, Point::new(5.0, 3.0)),
(2, Point::new(1e6, -5.0)),
(2, Point::new(f64::NAN, 1.0)),
(9, Point::new(1.0, 1.0)),
];
for (i, p) in probes {
let q = m.map(i, p);
assert!(q.x.is_finite() || !p.x.is_finite(), "{name} {adj:?} {r:?}: {q:?}");
assert!(q.y.is_finite(), "{name} {adj:?} {r:?}: {q:?}");
}
}
}
}
assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none());
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "deckcraft-metafile"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DeckCraft metafiles (ported from WordCraft): parses Windows WMF and EMF records into a neutral vector model (paths and bitmaps)"
[lints]
workspace = true
[dependencies]
kurbo = { workspace = true }
thiserror = { workspace = true }
+59
View File
@@ -0,0 +1,59 @@
//! Bounds-checked little-endian access to hostile input. Every read returns `None` past the end, so
//! a truncated or lying size field stops the parse instead of panicking.
#[derive(Clone, Copy)]
pub(crate) struct Bytes<'a>(pub(crate) &'a [u8]);
impl<'a> Bytes<'a> {
pub fn slice(&self, off: usize, len: usize) -> Option<&'a [u8]> {
self.0.get(off..off.checked_add(len)?)
}
pub fn tail(&self, off: usize) -> Option<Bytes<'a>> {
self.0.get(off..).map(Bytes)
}
fn arr<const N: usize>(&self, off: usize) -> Option<[u8; N]> {
self.slice(off, N)?.try_into().ok()
}
pub fn u16(&self, off: usize) -> Option<u16> {
Some(u16::from_le_bytes(self.arr(off)?))
}
pub fn i16(&self, off: usize) -> Option<i16> {
Some(i16::from_le_bytes(self.arr(off)?))
}
pub fn u32(&self, off: usize) -> Option<u32> {
Some(u32::from_le_bytes(self.arr(off)?))
}
pub fn i32(&self, off: usize) -> Option<i32> {
Some(i32::from_le_bytes(self.arr(off)?))
}
pub fn f32(&self, off: usize) -> Option<f32> {
Some(f32::from_le_bytes(self.arr(off)?))
}
/// `n` consecutive (x, y) pairs of 16- or 32-bit signed integers starting at `off`.
pub fn points(&self, off: usize, n: usize, wide: bool) -> Option<Vec<(f64, f64)>> {
let sz = if wide { 8 } else { 4 };
let raw = self.slice(off, n.checked_mul(sz)?)?;
let mut out = Vec::with_capacity(n);
for c in raw.chunks_exact(sz) {
let p = if wide {
let x = i32::from_le_bytes(c.get(0..4)?.try_into().ok()?);
let y = i32::from_le_bytes(c.get(4..8)?.try_into().ok()?);
(f64::from(x), f64::from(y))
} else {
let x = i16::from_le_bytes(c.get(0..2)?.try_into().ok()?);
let y = i16::from_le_bytes(c.get(2..4)?.try_into().ok()?);
(f64::from(x), f64::from(y))
};
out.push(p);
}
Some(out)
}
}
+574
View File
@@ -0,0 +1,574 @@
//! Drawing state shared by the WMF and EMF readers: window/viewport mapping and the EMF world
//! transform, pen and brush selection through an object table, the DC save stack and path brackets.
//! Output is device-space items; `lib.rs` maps them into the picture frame.
use crate::dib::Rgba;
use crate::geom::{Op, P};
use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK};
pub(crate) const MM_TEXT: u32 = 1;
const MM_ISOTROPIC: u32 = 7;
const MM_ANISOTROPIC: u32 = 8;
const STOCK: u32 = 0x8000_0000;
pub(crate) const PS_NULL: u32 = 5;
/// Pen style bits; the rest of the style word holds end-cap, join and type flags.
pub(crate) const PS_STYLE_MASK: u32 = 0x0F;
const BS_SOLID: u32 = 0;
pub(crate) const BS_NULL: u32 = 1;
const WINDING: u32 = 2;
/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing.
const R2_NOP: u32 = 11;
const PATCOPY: u32 = 0x00F0_0021;
const BLACKNESS: u32 = 0x0000_0042;
const WHITENESS: u32 = 0x00FF_0062;
/// Device pixels per millimetre assumed for WMF (96 dpi), and for EMF files without a device size.
const DEFAULT_PX_PER_MM: f64 = 96.0 / 25.4;
/// Affine transform `[m11, m12, m21, m22, dx, dy]` in EMF XFORM order: `x' = x·m11 + y·m21 + dx`.
pub(crate) type Xform = [f64; 6];
const IDENTITY: Xform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
/// `a` then `b` in EMF row-vector convention: the matrix product `a × b`.
pub(crate) fn mul(a: Xform, b: Xform) -> Xform {
[
a[0] * b[0] + a[1] * b[2],
a[0] * b[1] + a[1] * b[3],
a[2] * b[0] + a[3] * b[2],
a[2] * b[1] + a[3] * b[3],
a[4] * b[0] + a[5] * b[2] + b[4],
a[4] * b[1] + a[5] * b[3] + b[5],
]
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) struct Pen {
pub style: u32,
/// Logical width; 0 means a cosmetic one-pixel hairline.
pub width: f64,
pub color: [u8; 4],
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) struct Brush {
pub style: u32,
pub color: [u8; 4],
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum Obj {
Pen(Pen),
Brush(Brush),
/// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing.
Other,
}
/// Converts a COLORREF (0x00BBGGRR) to opaque RGBA.
pub(crate) fn colorref(c: u32) -> [u8; 4] {
[(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8, ((c >> 16) & 0xFF) as u8, 255]
}
/// Millimetres per logical unit of the metric map modes (LOMETRIC, HIMETRIC, LOENGLISH, HIENGLISH, TWIPS).
fn metric_mm(mode: u32) -> Option<f64> {
match mode {
2 => Some(0.1),
3 => Some(0.01),
4 => Some(25.4 / 100.0),
5 => Some(25.4 / 1000.0),
6 => Some(25.4 / 1440.0),
_ => None,
}
}
#[derive(Clone, Copy)]
pub(crate) struct Mapping {
wo: P,
we: P,
vo: P,
ve: P,
mode: u32,
world: Xform,
/// Device pixels per millimetre on each axis (EMF: from szlDevice and szlMillimeters).
px_mm: P,
}
impl Mapping {
fn identity() -> Self {
Mapping {
wo: (0.0, 0.0),
we: (1.0, 1.0),
vo: (0.0, 0.0),
ve: (1.0, 1.0),
mode: MM_TEXT,
world: IDENTITY,
px_mm: (DEFAULT_PX_PER_MM, DEFAULT_PX_PER_MM),
}
}
/// Logical point to device point: world transform, then window to viewport.
pub fn device(&self, p: P) -> P {
let w = self.world;
let (x, y) = (p.0 * w[0] + p.1 * w[2] + w[4], p.0 * w[1] + p.1 * w[3] + w[5]);
let (sx, sy) = self.ratios();
((x - self.wo.0) * sx + self.vo.0, (y - self.wo.1) * sy + self.vo.1)
}
/// Window-to-viewport scale per axis. MM_TEXT fixes the scale at 1; the metric modes use a fixed
/// scale with y up; MM_ISOTROPIC uses one scale on both axes (the smaller magnitude, each axis's
/// sign kept); anisotropic and unknown modes use the extents as given.
fn ratios(&self) -> (f64, f64) {
if self.mode == MM_TEXT {
return (1.0, 1.0);
}
if let Some(mm) = metric_mm(self.mode) {
return (mm * self.px_mm.0, -mm * self.px_mm.1);
}
let (rx, ry) = (ratio(self.ve.0, self.we.0), ratio(self.ve.1, self.we.1));
if self.mode == MM_ISOTROPIC {
let s = rx.abs().min(ry.abs());
return (if rx < 0.0 { -s } else { s }, if ry < 0.0 { -s } else { s });
}
(rx, ry)
}
/// Approximate device length per logical length, for pen widths.
fn scale(&self) -> f64 {
let w = self.world;
let det = (w[0] * w[3] - w[1] * w[2]).abs().sqrt();
let (sx, sy) = self.ratios();
det * (sx.abs() + sy.abs()) / 2.0
}
/// True when the window-to-viewport extents set the scale (the isotropic and anisotropic modes).
pub fn uses_extents(&self) -> bool {
matches!(self.mode, MM_ISOTROPIC | MM_ANISOTROPIC)
}
pub fn window_ext(&self) -> P {
self.we
}
pub fn viewport_ext(&self) -> P {
self.ve
}
/// The window in device units as `[x, y, w, h]` with positive extents, used as the picture frame
/// of a plain WMF.
pub fn window_rect(&self) -> [f64; 4] {
let a = self.device(self.wo);
let b = self.device((self.wo.0 + self.we.0, self.wo.1 + self.we.1));
[a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]
}
}
fn ratio(num: f64, den: f64) -> f64 {
if den == 0.0 { 1.0 } else { num / den }
}
/// Output of drawing, in device units. Path points are already mapped; `lib.rs` normalises them.
pub(crate) enum Raw {
Path { ops: Vec<Op>, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
Bitmap { rect: [f64; 4], img: Rgba },
}
#[derive(Clone)]
struct Saved {
map: Mapping,
pen: Pen,
brush: Brush,
even_odd: bool,
nop: bool,
}
pub(crate) struct Canvas {
pub items: Vec<Raw>,
pub map: Mapping,
/// True once SETWINDOWEXT was seen, so a plain WMF has a frame to use.
pub window_set: bool,
/// True once the file set the viewport extent itself (not the player, see [`Canvas::play_into`]).
pub viewport_set: bool,
saved: Vec<Saved>,
/// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`].
used: usize,
objects: Vec<Option<Obj>>,
pen: Pen,
brush: Brush,
even_odd: bool,
/// The mix mode is R2_NOP: shapes draw nothing.
nop: bool,
pos: P,
in_path: bool,
path: Vec<Op>,
full: bool,
}
impl Canvas {
pub fn new() -> Self {
Canvas {
items: Vec::new(),
map: Mapping::identity(),
window_set: false,
viewport_set: false,
saved: Vec::new(),
used: 0,
objects: Vec::new(),
pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] },
brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] },
even_odd: true,
nop: false,
pos: (0.0, 0.0),
in_path: false,
path: Vec::new(),
full: false,
}
}
/// True once the item cap is hit; the readers stop there and keep what they have.
pub fn full(&self) -> bool {
self.full
}
fn push(&mut self, raw: Raw) {
let cost = match &raw {
Raw::Path { ops, .. } => ops.len() * std::mem::size_of::<Op>(),
Raw::Bitmap { img, .. } => img.px.len(),
};
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
self.full = true;
} else {
self.used += cost;
self.items.push(raw);
}
}
pub fn set_window_org(&mut self, p: P) {
self.map.wo = p;
}
pub fn set_window_ext(&mut self, e: P) {
self.map.we = e;
self.window_set = true;
}
pub fn set_viewport_org(&mut self, p: P) {
self.map.vo = p;
}
pub fn set_viewport_ext(&mut self, e: P) {
self.map.ve = e;
self.viewport_set = true;
}
/// Sets up the mapping the way a player does before it plays a WMF into a target: anisotropic,
/// `window` (`[x, y, w, h]` in logical units) onto a viewport of `size` at the origin. The file's
/// own window records then replace the window; the viewport stays unless the file sets one.
/// Without a window (a plain WMF) only the viewport extent is set: the map mode and viewport
/// origin stay the file's.
pub fn play_into(&mut self, window: Option<[f64; 4]>, size: P) {
if let Some([x, y, w, h]) = window {
self.map.mode = MM_ANISOTROPIC;
self.map.wo = (x, y);
self.map.we = (w, h);
self.map.vo = (0.0, 0.0);
}
self.map.ve = size;
}
pub fn set_map_mode(&mut self, mode: u32) {
self.map.mode = mode;
}
/// Device pixels per millimetre (x, y) for the metric map modes. Ignored unless both are positive.
pub fn set_device_mm(&mut self, px_mm: P) {
if px_mm.0.is_finite() && px_mm.1.is_finite() && px_mm.0 > 0.0 && px_mm.1 > 0.0 {
self.map.px_mm = px_mm;
}
}
pub fn set_polyfill(&mut self, mode: u32) {
self.even_odd = mode != WINDING;
}
/// SETROP2: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy.
pub fn set_rop2(&mut self, mode: u32) {
self.nop = mode == R2_NOP;
}
/// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set).
/// An unknown mode leaves the world transform as it was.
pub fn modify_world(&mut self, x: Xform, mode: u32) {
match mode {
1 => self.map.world = IDENTITY,
2 => self.map.world = mul(x, self.map.world),
3 => self.map.world = mul(self.map.world, x),
4 => self.map.world = x,
_ => {}
}
}
/// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores
/// could no longer match the file.
pub fn save_dc(&mut self) {
if self.saved.len() < MAX_STACK {
self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop });
} else {
self.full = true;
}
}
/// Negative `n` pops `-n` levels; positive `n` restores absolute level `n`.
pub fn restore_dc(&mut self, n: i32) {
let idx = if n < 0 { self.saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) };
let Some(idx) = idx else { return };
let Some(s) = self.saved.get(idx).cloned() else { return };
self.saved.truncate(idx);
self.map = s.map;
self.pen = s.pen;
self.brush = s.brush;
self.even_odd = s.even_odd;
self.nop = s.nop;
}
/// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table).
pub fn add_object(&mut self, slot: Option<usize>, obj: Obj) {
match slot {
Some(i) if i < MAX_OBJECTS => {
if self.objects.len() <= i {
self.objects.resize(i + 1, None);
}
if let Some(s) = self.objects.get_mut(i) {
*s = Some(obj);
}
}
Some(_) => {}
None => match self.objects.iter().position(Option::is_none) {
Some(i) => {
if let Some(s) = self.objects.get_mut(i) {
*s = Some(obj);
}
}
None if self.objects.len() < MAX_OBJECTS => self.objects.push(Some(obj)),
None => {}
},
}
}
pub fn delete_object(&mut self, i: u32) {
if let Some(s) = self.objects.get_mut(i as usize) {
*s = None;
}
}
/// Selects an object by index; indices with the high bit set are stock objects.
pub fn select(&mut self, i: u32) {
if i & STOCK != 0 {
return self.select_stock(i & !STOCK);
}
match self.objects.get(i as usize).copied().flatten() {
Some(Obj::Pen(p)) => self.pen = p,
Some(Obj::Brush(b)) => self.brush = b,
Some(Obj::Other) | None => {}
}
}
fn select_stock(&mut self, i: u32) {
let white = [255, 255, 255, 255];
let black = [0, 0, 0, 255];
let gray = |v: u8| Brush { style: BS_SOLID, color: [v, v, v, 255] };
match i {
0 | 18 => self.brush = Brush { style: BS_SOLID, color: white },
1 => self.brush = gray(192),
2 => self.brush = gray(128),
3 => self.brush = gray(64),
4 => self.brush = Brush { style: BS_SOLID, color: black },
5 => self.brush = Brush { style: BS_NULL, color: black },
6 => self.pen = Pen { style: 0, width: 0.0, color: white },
7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black },
8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black },
_ => {}
}
}
fn map_op(&self, op: Op) -> Op {
match op {
Op::M(p) => Op::M(self.map.device(p)),
Op::L(p) => Op::L(self.map.device(p)),
Op::C(a, b, c) => Op::C(self.map.device(a), self.map.device(b), self.map.device(c)),
Op::Z => Op::Z,
}
}
fn fill_color(&self) -> Option<[u8; 4]> {
(self.brush.style == BS_SOLID).then_some(self.brush.color)
}
fn stroke_pen(&self) -> Option<([u8; 4], f64)> {
if (self.pen.style & PS_STYLE_MASK) == PS_NULL {
return None;
}
// A cosmetic pen (width 0) stays 0: the renderers draw that as a hairline.
let w = if self.pen.width == 0.0 { 0.0 } else { self.pen.width.abs() * self.map.scale() };
Some((self.pen.color, w))
}
fn push_path_ops(&mut self, ops: impl IntoIterator<Item = Op>) {
for op in ops {
if self.path.len() >= MAX_PATH_OPS {
return;
}
self.path.push(op);
}
}
/// Draws logical-space ops with the current pen (`stroke`) and brush (`fill`), or appends them to
/// the open path bracket, where fill and stroke wait for FILLPATH/STROKEPATH.
pub fn shape(&mut self, ops: &[Op], fill: bool, stroke: bool) {
let dev: Vec<Op> = ops.iter().map(|o| self.map_op(*o)).collect();
if self.in_path {
return self.push_path_ops(dev);
}
if self.nop {
return;
}
let fill = if fill { self.fill_color() } else { None };
let stroke = if stroke { self.stroke_pen() } else { None };
if (fill.is_none() && stroke.is_none()) || dev.len() < 2 {
return;
}
let even_odd = self.even_odd;
self.push(Raw::Path { ops: dev, fill, stroke, even_odd });
}
pub fn move_to(&mut self, p: P) {
if self.in_path {
self.push_path_ops([Op::M(self.map.device(p))]);
}
self.pos = p;
}
/// LINETO from the current point, then moves the current point to `p`.
pub fn line_to(&mut self, p: P) {
self.line_chain(&[p], false, true);
}
/// Lines from the current point through `pts` (POLYLINETO), or a polyline from the current point.
pub fn line_chain(&mut self, pts: &[P], fill: bool, stroke: bool) {
// Inside an open bracket the path already ends at the current point, so no MoveTo is needed.
let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] };
ops.extend(pts.iter().map(|p| Op::L(*p)));
if let Some(last) = pts.last() {
self.pos = *last;
}
self.shape(&ops, fill, stroke);
}
/// POLYBEZIER: cubic segments that start a new figure at `from`. The current point is unchanged.
pub fn bezier(&mut self, from: P, pts: &[P]) {
let mut ops = vec![Op::M(from)];
ops.extend(cubics(pts));
self.shape(&ops, false, true);
}
/// POLYBEZIERTO: cubic segments from the current point through groups of three control or end
/// points; the current point moves to the last point. Inside an open bracket the path already
/// ends at the current point, so no MoveTo is needed.
pub fn bezier_to(&mut self, pts: &[P]) {
let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] };
ops.extend(cubics(pts));
if let Some(last) = pts.last() {
self.pos = *last;
}
self.shape(&ops, false, true);
}
pub fn poly(&mut self, pts: &[P], close: bool, fill: bool, stroke: bool) {
let Some(first) = pts.first() else { return };
let mut ops = vec![Op::M(*first)];
ops.extend(pts.iter().skip(1).map(|p| Op::L(*p)));
if close {
ops.push(Op::Z);
}
self.shape(&ops, fill, stroke);
}
pub fn begin_path(&mut self) {
self.in_path = true;
self.path.clear();
}
pub fn end_path(&mut self) {
self.in_path = false;
}
pub fn close_figure(&mut self) {
if self.in_path {
self.push_path_ops([Op::Z]);
}
}
/// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it.
pub fn paint_path(&mut self, fill: bool, stroke: bool) {
let ops = std::mem::take(&mut self.path);
if self.nop {
return;
}
let fill = if fill { self.fill_color() } else { None };
let stroke = if stroke { self.stroke_pen() } else { None };
if ops.len() < 2 || (fill.is_none() && stroke.is_none()) {
return;
}
let even_odd = self.even_odd;
self.push(Raw::Path { ops, fill, stroke, even_odd });
}
/// Fills a logical rectangle `[l, t, r, b]` with the current brush (nothing when it is not solid).
pub fn fill_brush_rect(&mut self, rect: [f64; 4]) {
if let Some(color) = self.fill_color() {
self.fill_rect_with(rect, color);
}
}
/// Fills a logical rectangle `[l, t, r, b]` with a solid colour (ROP fills such as BLACKNESS).
pub fn fill_rect_with(&mut self, [l, t, r, b]: [f64; 4], color: [u8; 4]) {
let dev: Vec<Op> = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect();
self.push(Raw::Path { ops: dev, fill: Some(color), stroke: None, even_odd: true });
}
/// Whether a raster operation draws without a source bitmap (PATCOPY, BLACKNESS, WHITENESS).
pub fn sourceless(rop: u32) -> bool {
matches!(rop, PATCOPY | BLACKNESS | WHITENESS)
}
/// Fills a logical rectangle `[x, y, w, h]` for a source-less raster operation. PATCOPY uses the
/// brush (when solid); the others fill black or white. Other operations are not handled here.
pub fn fill_rop(&mut self, [x, y, w, h]: [f64; 4], rop: u32) {
let rect = [x, y, x + w, y + h];
match rop {
PATCOPY => self.fill_brush_rect(rect),
BLACKNESS => self.fill_rect_with(rect, [0, 0, 0, 255]),
WHITENESS => self.fill_rect_with(rect, [255, 255, 255, 255]),
_ => {}
}
}
/// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the
/// bitmap, as the mapping does.
pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) {
let [x, y, w, h] = rect;
let a = self.map.device((x, y));
let b = self.map.device((x + w, y + h));
crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1);
let dev = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()];
self.push(Raw::Bitmap { rect: dev, img });
}
}
/// Cubic segments from groups of three points (control, control, end). A trailing partial group is
/// dropped.
fn cubics(pts: &[P]) -> Vec<Op> {
let mut ops = Vec::with_capacity(pts.len() / 3);
let mut it = pts.iter().copied();
while let (Some(a), Some(b), Some(e)) = (it.next(), it.next(), it.next()) {
ops.push(Op::C(a, b, e));
}
ops
}
+259
View File
@@ -0,0 +1,259 @@
//! Device-independent bitmaps. BITMAPINFOHEADER DIBs at 1/4/8/16/24/32 bpp with BI_RGB or BI_BITFIELDS
//! decode to RGBA8 rows, top-down. Compressed (RLE, JPEG, PNG) and V4/V5-only features are not decoded.
use crate::{MAX_PIXELS, bytes::Bytes};
const BI_RGB: u32 = 0;
const BI_BITFIELDS: u32 = 3;
/// Most palette entries a DIB can carry (the colour table never exceeds 2^8).
const MAX_PALETTE: usize = 256;
const ROP_SRCAND: u32 = 0x0088_00C6;
const ROP_SRCPAINT: u32 = 0x00EE_0086;
const ROP_SRCINVERT: u32 = 0x0066_0046;
/// A decoded picture. `bottom_up` records the DIB's native row order, which source rectangles use.
pub(crate) struct Rgba {
pub w: usize,
pub h: usize,
pub bottom_up: bool,
pub px: Vec<u8>,
}
/// How a decoded bitmap combines with the destination.
#[derive(Clone, Copy)]
pub(crate) enum Blend {
/// A raster operation (BITBLT, STRETCHDIB and friends).
Rop(u32),
/// EMR_ALPHABLEND: a constant alpha (0..=255), times the per-pixel alpha when `per_pixel` is set
/// (AC_SRC_ALPHA: the source colours are premultiplied by it).
Alpha { constant: u8, per_pixel: bool },
}
impl Blend {
/// Whether the decoder must keep the 32-bit alpha byte of each pixel.
pub fn per_pixel(self) -> bool {
matches!(self, Blend::Alpha { per_pixel: true, .. })
}
}
struct Header {
w: usize,
h: usize,
top_down: bool,
bpp: usize,
masks: [u32; 3],
pal_off: usize,
pal_n: usize,
}
impl Header {
fn read(info: Bytes) -> Option<Header> {
let size = info.u32(0)? as usize;
let w = info.i32(4)?;
let h = info.i32(8)?;
let bpp = info.u16(14)? as usize;
let comp = info.u32(16)?;
let clr_used = info.u32(32)? as usize;
if size < 40 || w <= 0 || h == 0 || !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) || !matches!(comp, BI_RGB | BI_BITFIELDS) {
return None;
}
let masks = if comp == BI_BITFIELDS {
[info.u32(40)?, info.u32(44)?, info.u32(48)?]
} else if bpp == 16 {
[0x7C00, 0x03E0, 0x001F]
} else {
[0xFF_0000, 0xFF00, 0xFF]
};
let masks_len = if comp == BI_BITFIELDS && size == 40 { 12 } else { 0 };
let pal_off = size.checked_add(masks_len)?;
// Indexed formats take 2^bpp entries when clrUsed is 0. Deeper formats carry only the
// entries clrUsed names (WMF DIBs can have them), still capped.
let pal_n = match (bpp <= 8, clr_used) {
(true, 0) => 1usize << bpp,
(true, n) => n.min(1usize << bpp),
(false, n) => n.min(MAX_PALETTE),
};
Some(Header { w: w as usize, h: h.unsigned_abs() as usize, top_down: h < 0, bpp, masks, pal_off, pal_n })
}
}
/// Byte length of the info part (header, masks, palette) of a DIB, so the pixel bits follow it.
pub(crate) fn info_len(dib: Bytes) -> Option<usize> {
let h = Header::read(dib)?;
h.pal_off.checked_add(h.pal_n.checked_mul(4)?)
}
/// Decodes a DIB given as its info part (header plus masks and palette) and its pixel bits. The
/// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels
/// are opaque.
pub(crate) fn decode(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option<Rgba> {
let hd = Header::read(info)?;
let pixels = hd.w.checked_mul(hd.h)?;
if pixels == 0 || pixels > MAX_PIXELS {
return None;
}
let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4;
let data = bits.slice(0, stride.checked_mul(hd.h)?)?;
let pal = info.slice(hd.pal_off, hd.pal_n * 4)?;
let mut px = vec![0u8; pixels * 4];
for y in 0..hd.h {
let src_row = if hd.top_down { y } else { hd.h - 1 - y };
let row = src_row * stride;
for x in 0..hd.w {
let p = pixel(data, row, x, &hd, pal, keep_alpha)?;
let o = (y * hd.w + x) * 4;
px.get_mut(o..o + 4)?.copy_from_slice(&p);
}
}
Some(Rgba { w: hd.w, h: hd.h, bottom_up: !hd.top_down, px })
}
fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> {
let [m_r, m_g, m_b] = hd.masks;
let [r, g, b] = match hd.bpp {
1 => {
let b = *data.get(row + x / 8)?;
palette(pal, usize::from((b >> (7 - x % 8)) & 1))
}
4 => {
let b = *data.get(row + x / 2)?;
palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }))
}
8 => palette(pal, usize::from(*data.get(row + x)?)),
24 => {
let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?;
[r, g, b]
}
16 => {
let o = row + x * 2;
let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?));
[field(v, m_r), field(v, m_g), field(v, m_b)]
}
_ => {
let o = row + x * 4;
let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?);
let a = if keep_alpha { (v >> 24) as u8 } else { 255 };
return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]);
}
};
Some([r, g, b, 255])
}
/// Palette entries are BGRX quads; an index past the table reads as black rather than failing the image.
fn palette(pal: &[u8], i: usize) -> [u8; 3] {
pal.get(i * 4..i * 4 + 4).and_then(|c| <[u8; 4]>::try_from(c).ok()).map_or([0, 0, 0], |c| [c[2], c[1], c[0]])
}
/// Extracts one channel from a masked pixel value and scales it to 8 bits.
fn field(v: u32, mask: u32) -> u8 {
if mask == 0 {
return 0;
}
let shift = mask.trailing_zeros();
let max = u64::from(mask >> shift);
let val = u64::from((v & mask) >> shift);
((val * 255 + max / 2) / max) as u8
}
/// Clips a source rectangle `src` = `[x, y, w, h]` to the bitmap and returns the cropped pixels (top-down)
/// with the part of the destination `dst` that they fill. `src` is in DIB pixel coordinates (`y` counted
/// from the DIB's origin, so bottom-up images count from the bottom); a non-positive source size means the
/// whole bitmap. Clipping shrinks the destination by the same ratio and shifts it by the clipped-off part,
/// so the rest of the image keeps its place. A negative destination extent is kept (the caller mirrors).
pub(crate) fn clip_blit(img: &Rgba, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> {
let (iw, ih) = (img.w as i64, img.h as i64);
let [sx, sy, sw, sh] = if src[2] > 0 && src[3] > 0 { src } else { [0, 0, iw, ih] };
// The request's top row in top-down order.
let top = if img.bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy };
let (x0, x1) = (sx.max(0), sx.saturating_add(sw).min(iw));
let (r0, r1) = (top.max(0), top.saturating_add(sh).min(ih));
if x1 <= x0 || r1 <= r0 {
return None;
}
let (kx, ky) = (dst[2] / sw as f64, dst[3] / sh as f64);
let out = [dst[0] + (x0 - sx) as f64 * kx, dst[1] + (r0 - top) as f64 * ky, (x1 - x0) as f64 * kx, (r1 - r0) as f64 * ky];
let (x0, x1, r0, r1) = (x0 as usize, x1 as usize, r0 as usize, r1 as usize);
let (ow, oh) = (x1 - x0, r1 - r0);
let mut px = Vec::with_capacity(ow * oh * 4);
for row in r0..r1 {
let s = (row * img.w + x0) * 4;
px.extend_from_slice(img.px.get(s..s + ow * 4)?);
}
Some((Rgba { w: ow, h: oh, bottom_up: false, px }, out))
}
/// Applies a blend to decoded pixels before they are drawn. The picture has no destination pixels, so
/// raster operations are drawn as they look over white paper: SRCAND (destination AND source) leaves
/// white source pixels out and draws the rest; SRCPAINT and SRCINVERT leave black ones out. Other
/// operations draw the source as copied. Constant alpha scales every pixel's alpha, after a
/// per-pixel (premultiplied) source is made straight.
pub(crate) fn prepare(img: &mut Rgba, blend: Blend) {
match blend {
Blend::Rop(ROP_SRCAND) => clear_where(img, [255, 255, 255]),
Blend::Rop(ROP_SRCPAINT | ROP_SRCINVERT) => clear_where(img, [0, 0, 0]),
Blend::Rop(_) => {}
Blend::Alpha { constant, per_pixel } => {
for p in img.px.as_chunks_mut::<4>().0 {
if per_pixel {
unpremultiply(p);
}
p[3] = (u16::from(p[3]) * u16::from(constant) / 255) as u8;
}
}
}
}
/// Makes the pixels of colour `rgb` transparent: where a raster operation keeps the destination.
fn clear_where(img: &mut Rgba, rgb: [u8; 3]) {
for p in img.px.as_chunks_mut::<4>().0 {
if p[..3] == rgb {
p[3] = 0;
}
}
}
/// AC_SRC_ALPHA sources are premultiplied; the model holds straight RGBA, so divide the colour by the
/// alpha (rounded, capped at 255). A transparent pixel keeps its colour bytes.
fn unpremultiply(p: &mut [u8; 4]) {
let a = u32::from(p[3]);
if a == 0 || a == 255 {
return;
}
for c in &mut p[..3] {
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
}
}
/// Mirrors a decoded image left-right and/or top-bottom. Rows come from the buffer as chunks, so a
/// short buffer flips only the rows it has.
pub(crate) fn flip(img: &mut Rgba, horizontal: bool, vertical: bool) {
let (w, h) = (img.w, img.h);
if w == 0 || h == 0 {
return;
}
let row = w * 4;
if horizontal {
for line in img.px.chunks_exact_mut(row) {
for x in 0..w / 2 {
for c in 0..4 {
line.swap(x * 4 + c, (w - 1 - x) * 4 + c);
}
}
}
}
if vertical {
// Rows the buffer actually holds: a short buffer flips only those.
let n = h.min(img.px.len() / row);
for y in 0..n / 2 {
swap_rows(&mut img.px, y * row, (n - 1 - y) * row, row);
}
}
}
/// Swaps the `len` bytes at `a` with those at `b` (`a < b`); does nothing when either is out of range.
fn swap_rows(px: &mut [u8], a: usize, b: usize, len: usize) {
let Some((head, tail)) = px.split_at_mut_checked(b) else { return };
if let (Some(top), Some(bottom)) = (head.get_mut(a..a.saturating_add(len)), tail.get_mut(..len)) {
top.swap_with_slice(bottom);
}
}
+315
View File
@@ -0,0 +1,315 @@
//! EMF reader (MS-EMF). Records are (type, size in bytes) followed by a payload; offsets inside a
//! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice.
use crate::bytes::Bytes;
use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref};
use crate::dib::{self, Blend};
use crate::geom::{self, ArcKind, P};
use crate::{Error, Frame, MAX_PATH_OPS};
const EMF_SIGNATURE: u32 = 0x464D_4520;
/// Hundredths of a millimetre to points.
const HMM_TO_PT: f64 = 72.0 / 2540.0;
/// SRCCOPY, the plain copy of a bitmap.
const SRCCOPY: u32 = 0x00CC_0020;
/// LogPenEx pen type bits; PS_COSMETIC pens are one device pixel wide.
const PS_TYPE_MASK: u32 = 0x000F_0000;
const PS_COSMETIC: u32 = 0;
pub(crate) fn is_emf(b: Bytes) -> bool {
b.u32(0) == Some(1) && b.u32(40) == Some(EMF_SIGNATURE)
}
/// The picture frame: `rclFrame` (0.01 mm) mapped to device units through `szlDevice` and
/// `szlMillimeters`, sized in points. Without a usable frame, the device bounds stand in (1 unit = 1 point).
pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
if !is_emf(input) {
return Err(Error::BadHeader);
}
let hsize = input.u32(4).ok_or(Error::BadHeader)? as usize;
let bounds = rect32(input, 8).ok_or(Error::BadHeader)?;
let [l, t, r, b] = bounds;
let frame = frame_from_header(input).or_else(|| (r > l && b > t).then_some(Frame { rect: [l, t, r - l, b - t], size: None }));
let mut cv = Canvas::new();
if let Some(px_mm) = device_px_per_mm(input) {
cv.set_device_mm(px_mm);
}
let mut parsed = 0usize;
let mut pos = hsize.max(88);
while !cv.full() {
let (Some(typ), Some(size)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u32(o))) else { break };
let size = size as usize;
if size < 8 || !size.is_multiple_of(4) {
break;
}
let Some(rec) = input.slice(pos, size) else { break };
if typ == 14 {
break;
}
record(&mut cv, typ, Bytes(rec));
parsed += 1;
let Some(next) = pos.checked_add(size) else { break };
pos = next;
}
if parsed == 0 {
return Err(Error::NoRecords);
}
Ok((cv, frame))
}
/// Device pixels per millimetre on each axis, from `szlDevice` (72) and `szlMillimeters` (80).
fn device_px_per_mm(input: Bytes) -> Option<(f64, f64)> {
let (dw, dh) = (input.i32(72)?, input.i32(76)?);
let (mw, mh) = (input.i32(80)?, input.i32(84)?);
(dw > 0 && dh > 0 && mw > 0 && mh > 0).then(|| (f64::from(dw) / f64::from(mw), f64::from(dh) / f64::from(mh)))
}
/// The frame from `rclFrame` (offset 24), `szlDevice` (72) and `szlMillimeters` (80) in the header.
fn frame_from_header(input: Bytes) -> Option<Frame> {
let [fl, ft, fr, fb] = rect32(input, 24)?;
if fr <= fl || fb <= ft {
return None;
}
// Device units per hundredth of a millimetre.
let (px_mm_x, px_mm_y) = device_px_per_mm(input)?;
let (sx, sy) = (px_mm_x / 100.0, px_mm_y / 100.0);
let rect = [fl * sx, ft * sy, (fr - fl) * sx, (fb - ft) * sy];
Some(Frame { rect, size: Some(((fr - fl) * HMM_TO_PT, (fb - ft) * HMM_TO_PT)) })
}
fn record(cv: &mut Canvas, typ: u32, r: Bytes) {
if cv.full() {
return;
}
let _ = match typ {
9 => xy32(r, 8).map(|p| cv.set_window_ext(p)),
10 => xy32(r, 8).map(|p| cv.set_window_org(p)),
11 => xy32(r, 8).map(|p| cv.set_viewport_ext(p)),
12 => xy32(r, 8).map(|p| cv.set_viewport_org(p)),
17 => r.u32(8).map(|m| cv.set_map_mode(m)),
19 => r.u32(8).map(|m| cv.set_polyfill(m)),
20 => r.u32(8).map(|m| cv.set_rop2(m)),
33 => {
cv.save_dc();
Some(())
}
34 => r.i32(8).map(|n| cv.restore_dc(n)),
35 => xform(r, 8).map(|x| cv.modify_world(x, 4)),
36 => xform(r, 8).zip(r.u32(32)).map(|(x, m)| cv.modify_world(x, m)),
37 => r.u32(8).map(|i| cv.select(i)),
38 => create_pen(cv, r),
95 => create_ext_pen(cv, r),
39 => create_brush(cv, r),
40 => r.u32(8).map(|i| cv.delete_object(i)),
27 => xy32(r, 8).map(|p| cv.move_to(p)),
54 => xy32(r, 8).map(|p| cv.line_to(p)),
42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
43 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)),
44 => {
let (cw, ch) = (r.i32(24), r.i32(28));
let (Some(cw), Some(ch)) = (cw, ch) else { return };
rect32(r, 8).map(|[l, t, rr, b]| {
cv.shape(&geom::round_rect(l, t, rr, b, f64::from(cw), f64::from(ch)), true, true);
})
}
45..=47 => arc32(r).map(|(bounds, s, e)| {
let kind = match typ {
45 => ArcKind::Arc,
46 => ArcKind::Chord,
_ => ArcKind::Pie,
};
let [l, t, rr, b] = bounds;
cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true);
}),
4 | 87 => counted(r, typ == 4).map(|pts| cv.poly(&pts, false, false, true)),
3 | 86 => counted(r, typ == 3).map(|pts| cv.poly(&pts, true, true, true)),
6 | 89 => counted(r, typ == 6).map(|pts| cv.line_chain(&pts, false, true)),
// POLYBEZIER starts its own figure at the first point and leaves the current point alone;
// POLYBEZIERTO continues from the current point.
2 | 85 => counted(r, typ == 2).and_then(|pts| {
let from = *pts.first()?;
cv.bezier(from, pts.get(1..)?);
Some(())
}),
5 | 88 => counted(r, typ == 5).map(|pts| cv.bezier_to(&pts)),
7 | 90 => poly_groups(r, typ == 7).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, false), false, true)),
8 | 91 => poly_groups(r, typ == 8).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, true), true, true)),
59 => {
cv.begin_path();
Some(())
}
60 => {
cv.end_path();
Some(())
}
61 => {
cv.close_figure();
Some(())
}
62 => {
cv.paint_path(true, false);
Some(())
}
63 => {
cv.paint_path(true, true);
Some(())
}
64 => {
cv.paint_path(false, true);
Some(())
}
76 | 77 => bitblt(cv, r, typ == 77),
80 => set_dibits(cv, r),
81 => stretch_dibits(cv, r),
114 => alphablend(cv, r),
_ => Some(()),
};
}
/// Two little-endian i32 values at `off`, as a point.
fn xy32(r: Bytes, off: usize) -> Option<P> {
Some((f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?)))
}
/// RECTL (left, top, right, bottom) as i32 values widened to f64.
fn rect32(r: Bytes, off: usize) -> Option<[f64; 4]> {
Some([f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?), f64::from(r.i32(off + 8)?), f64::from(r.i32(off + 12)?)])
}
/// An i32 field as f64; 0 past the end.
fn i32s(r: Bytes, off: usize) -> f64 {
r.i32(off).map_or(0.0, f64::from)
}
/// XFORM: six f32 values in M11, M12, M21, M22, Dx, Dy order.
fn xform(r: Bytes, off: usize) -> Option<Xform> {
let mut x = [0.0f64; 6];
for (i, v) in x.iter_mut().enumerate() {
*v = f64::from(r.f32(off + i * 4)?);
}
Some(x)
}
/// ARC, CHORD, PIE: bounds at 8, start point at 24, end point at 32.
fn arc32(r: Bytes) -> Option<([f64; 4], P, P)> {
Some((rect32(r, 8)?, xy32(r, 24)?, xy32(r, 32)?))
}
/// A point count from a record, rejected when it exceeds the path budget (before any allocation).
fn budget(n: u32) -> Option<usize> {
let n = n as usize;
(n <= MAX_PATH_OPS).then_some(n)
}
/// Point count at 24, then the points (32-bit or 16-bit) at 28.
fn counted(r: Bytes, wide: bool) -> Option<Vec<P>> {
let n = budget(r.u32(24)?)?;
r.tail(28)?.points(0, n, wide)
}
/// Subpath count at 24, total point count at 28, per-subpath counts at 32, then the points.
fn poly_groups(r: Bytes, wide: bool) -> Option<(Vec<usize>, Vec<P>)> {
let n = budget(r.u32(24)?)?;
// The record must hold the n counts before anything is allocated for them.
let raw = r.slice(32, n.checked_mul(4)?)?;
let counts: Vec<usize> = raw.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes(*c) as usize).collect();
let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?;
let total = budget(u32::try_from(total).ok()?)?;
let pts = r.tail(n.checked_mul(4)?.checked_add(32)?)?.points(0, total, wide)?;
Some((counts, pts))
}
/// EMR_CREATEPEN: handle, LogPen (style, width x/y, colour).
fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let style = r.u32(12)?;
let width = r.i32(16)?;
let color = r.u32(24)?;
cv.add_object(Some(slot), Obj::Pen(Pen { style, width: f64::from(width), color: colorref(color) }));
Some(())
}
/// EMR_EXTCREATEPEN: handle, bitmap offsets (unused), LogPenEx (style, width, brush style, colour).
/// A cosmetic pen's width is in device pixels (always 1), so it is a hairline; a BS_NULL brush draws
/// no line at all.
fn create_ext_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let (style, width, brush, color) = (r.u32(28)?, r.u32(32)?, r.u32(36)?, r.u32(40)?);
let width = if style & PS_TYPE_MASK == PS_COSMETIC { 0.0 } else { f64::from(width) };
let style = if brush == BS_NULL { PS_NULL } else { style & PS_STYLE_MASK };
cv.add_object(Some(slot), Obj::Pen(Pen { style, width, color: colorref(color) }));
Some(())
}
/// EMR_CREATEBRUSHINDIRECT: handle, LogBrush32 (style, colour, hatch).
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let style = r.u32(12)?;
let color = r.u32(16)?;
cv.add_object(Some(slot), Obj::Brush(Brush { style, color: colorref(color) }));
Some(())
}
/// BITBLT (76) and STRETCHBLT (77): dest at 24..40, rop at 40, source x/y at 44, bmi at 84, bits at
/// 92; STRETCHBLT adds the source w/h at 100. A source-less rop fills the destination instead.
fn bitblt(cv: &mut Canvas, r: Bytes, stretch: bool) -> Option<()> {
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)];
let rop = r.u32(40)?;
if Canvas::sourceless(rop) {
cv.fill_rop(dst, rop);
return Some(());
}
let (sw, sh) = if stretch { (i32s(r, 100), i32s(r, 104)) } else { (dst[2], dst[3]) };
let src = [i32s(r, 44), i32s(r, 48), sw, sh];
blit(cv, r, dst, src, (84, 92), Blend::Rop(rop))
}
/// ALPHABLEND: laid out like STRETCHBLT, with a BLENDFUNCTION at 40. Constant alpha at 42 applies to
/// every pixel; AlphaFormat at 43 with AC_SRC_ALPHA (1) also uses the per-pixel alpha.
fn alphablend(cv: &mut Canvas, r: Bytes) -> Option<()> {
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)];
let constant = *r.0.get(42)?;
let per_pixel = (*r.0.get(43)? & 0x01) != 0;
let src = [i32s(r, 44), i32s(r, 48), i32s(r, 100), i32s(r, 104)];
blit(cv, r, dst, src, (84, 92), Blend::Alpha { constant, per_pixel })
}
/// STRETCHDIBITS: dest x/y at 24, src x/y/w/h at 32..48, bmi offset/size at 48, bits at 56, rop at 68,
/// dest w/h at 72.
fn stretch_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> {
let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 72), i32s(r, 76)];
let src = [i32s(r, 32), i32s(r, 36), i32s(r, 40), i32s(r, 44)];
let rop = r.u32(68)?;
blit(cv, r, dst, src, (48, 56), Blend::Rop(rop))
}
/// SETDIBITSTODEVICE (MS-EMF 2.3.1.5): dest x/y at 24, src x/y at 32, size (cxSrc, cySrc) at 40, bmi
/// at 48, bits at 56, start scan at 68. The image keeps its own size; the bits begin at scan line
/// `iStartScan`, so the source y counts from there.
fn set_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> {
let (cx, cy) = (i32s(r, 40), i32s(r, 44));
if cx <= 0.0 || cy <= 0.0 {
return None;
}
let dst = [i32s(r, 24), i32s(r, 28), cx, cy];
let src = [i32s(r, 32), i32s(r, 36) - f64::from(r.u32(68)?), cx, cy];
blit(cv, r, dst, src, (48, 56), Blend::Rop(SRCCOPY))
}
/// Decodes the bitmap at `at` = (offset of the bmi offset/length pair, offset of the bits pair), both
/// inside the record, and stretches the source rectangle `src` into `dst`. Both are `[x, y, w, h]`; a
/// non-positive source size means the whole bitmap. `blend` decides how it combines.
fn blit(cv: &mut Canvas, r: Bytes, dst: [f64; 4], src: [f64; 4], at: (usize, usize), blend: Blend) -> Option<()> {
let (bo, bl) = (r.u32(at.0)? as usize, r.u32(at.0 + 4)? as usize);
let (po, pl) = (r.u32(at.1)? as usize, r.u32(at.1 + 4)? as usize);
if pl == 0 {
return None;
}
let info = Bytes(r.slice(bo, bl)?);
let pixels = Bytes(r.slice(po, pl)?);
let mut img = dib::decode(info, pixels, blend.per_pixel())?;
dib::prepare(&mut img, blend);
let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?;
cv.bitmap(dst, img);
Some(())
}
+118
View File
@@ -0,0 +1,118 @@
//! Path operations in `f64` and the shape builders that turn WMF/EMF rectangles, ellipses and arcs
//! into cubic Béziers (the classic 4/3·tan(Δ/4) approximation, at most 90° per piece).
use std::f64::consts::{FRAC_PI_2, PI, TAU};
pub(crate) type P = (f64, f64);
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Op {
M(P),
L(P),
C(P, P, P),
Z,
}
pub(crate) fn rect(l: f64, t: f64, r: f64, b: f64) -> Vec<Op> {
vec![Op::M((l, t)), Op::L((r, t)), Op::L((r, b)), Op::L((l, b)), Op::Z]
}
/// Corner radii are half the EMF/WMF corner ellipse size, clamped so the corners never overlap.
pub(crate) fn round_rect(l: f64, t: f64, r: f64, b: f64, ew: f64, eh: f64) -> Vec<Op> {
// Reversed corners (right < left, bottom < top) describe the same rectangle.
let (l, r) = (l.min(r), l.max(r));
let (t, b) = (t.min(b), t.max(b));
let rx = (ew / 2.0).abs().min((r - l) / 2.0);
let ry = (eh / 2.0).abs().min((b - t) / 2.0);
let mut ops = vec![Op::M((l + rx, t)), Op::L((r - rx, t))];
ops.extend(arc(r - rx, t + ry, rx, ry, -FRAC_PI_2, FRAC_PI_2));
ops.push(Op::L((r, b - ry)));
ops.extend(arc(r - rx, b - ry, rx, ry, 0.0, FRAC_PI_2));
ops.push(Op::L((l + rx, b)));
ops.extend(arc(l + rx, b - ry, rx, ry, FRAC_PI_2, FRAC_PI_2));
ops.push(Op::L((l, t + ry)));
ops.extend(arc(l + rx, t + ry, rx, ry, PI, FRAC_PI_2));
ops.push(Op::Z);
ops
}
pub(crate) fn ellipse(l: f64, t: f64, r: f64, b: f64) -> Vec<Op> {
let (cx, cy, rx, ry) = center(l, t, r, b);
let mut ops = vec![Op::M((cx + rx, cy))];
ops.extend(arc(cx, cy, rx, ry, 0.0, TAU));
ops.push(Op::Z);
ops
}
/// Which closed or open shape an ARC/CHORD/PIE record draws.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ArcKind {
Arc,
Chord,
Pie,
}
/// ARC, CHORD and PIE: the arc runs counter-clockwise on screen from the start point to the end point.
pub(crate) fn arc_shape(l: f64, t: f64, r: f64, b: f64, s: P, e: P, kind: ArcKind) -> Vec<Op> {
let (cx, cy, rx, ry) = center(l, t, r, b);
let ang = |p: P| ((p.1 - cy) / ry.max(f64::MIN_POSITIVE)).atan2((p.0 - cx) / rx.max(f64::MIN_POSITIVE));
let (a0, a1) = (ang(s), ang(e));
let mut d = (a0 - a1).rem_euclid(TAU);
if d < 1e-12 {
d = TAU;
}
let sweep = -d;
let start = (cx + rx * a0.cos(), cy + ry * a0.sin());
let mut ops = if kind == ArcKind::Pie { vec![Op::M((cx, cy)), Op::L(start)] } else { vec![Op::M(start)] };
ops.extend(arc(cx, cy, rx, ry, a0, sweep));
match kind {
ArcKind::Arc => {}
ArcKind::Chord => ops.push(Op::Z),
ArcKind::Pie => ops.extend([Op::L((cx, cy)), Op::Z]),
}
ops
}
fn center(l: f64, t: f64, r: f64, b: f64) -> (f64, f64, f64, f64) {
((l + r) / 2.0, (t + b) / 2.0, (r - l).abs() / 2.0, (b - t).abs() / 2.0)
}
/// Cubic pieces along an ellipse from parameter `a0` over signed `sweep` radians. The current point
/// must already be at `(cx + rx cos a0, cy + ry sin a0)`.
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, sweep: f64) -> Vec<Op> {
let n = ((sweep.abs() / FRAC_PI_2).ceil() as usize).clamp(1, 8);
let step = sweep / n as f64;
let k = 4.0 / 3.0 * (step / 4.0).tan();
let pt = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin());
let mut ops = Vec::with_capacity(n);
for i in 0..n {
let t0 = a0 + step * i as f64;
let t1 = t0 + step;
let (p0, p3) = (pt(t0), pt(t1));
let c1 = (p0.0 - k * rx * t0.sin(), p0.1 + k * ry * t0.cos());
let c2 = (p3.0 + k * rx * t1.sin(), p3.1 - k * ry * t1.cos());
ops.push(Op::C(c1, c2, p3));
}
ops
}
/// Consecutive subpaths: `counts[i]` points each, taken in order from `pts`. Counts that run past the
/// point list are cut short rather than failing the whole record.
pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec<Op> {
let mut ops = Vec::with_capacity(pts.len() + counts.len());
let mut at = 0usize;
for &c in counts {
let end = at.saturating_add(c).min(pts.len());
let Some((first, rest)) = pts.get(at..end).and_then(|s| s.split_first()) else {
at = end;
continue;
};
ops.push(Op::M(*first));
ops.extend(rest.iter().map(|p| Op::L(*p)));
if close {
ops.push(Op::Z);
}
at = end;
}
ops
}
+208
View File
@@ -0,0 +1,208 @@
//! DeckCraft metafiles (ported from WordCraft's `crates/metafile`): reads Windows Metafiles (WMF)
//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths and RGBA
//! bitmaps, in a picture frame with y down.
//!
//! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is
//! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`],
//! never a panic.
//!
//! Records follow MS-WMF and MS-EMF. Records outside the supported set are skipped silently.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod bytes;
mod canvas;
mod dib;
mod emf;
mod geom;
mod place;
mod wmf;
#[cfg(test)]
mod tests;
use bytes::Bytes;
use canvas::Raw;
use geom::{Op, P};
pub use place::{MAX_BITMAP_SIDE, PlacedItem};
/// Items kept from one file. Past the cap the parse stops and keeps what it has.
pub(crate) const MAX_ITEMS: usize = 200_000;
/// Object table slots (WMF handles, EMF object indices).
pub(crate) const MAX_OBJECTS: usize = 4096;
/// Largest bitmap in pixels (about 40 megapixels, 160 MB of RGBA).
pub(crate) const MAX_PIXELS: usize = 40_000_000;
/// DC save-stack depth.
pub(crate) const MAX_STACK: usize = 1024;
/// Points in one open path bracket.
pub(crate) const MAX_PATH_OPS: usize = 2_000_000;
/// Bytes of path ops and pixels kept from one file (about 256 MB).
pub(crate) const MAX_BYTES: usize = 256 * 1024 * 1024;
/// A parsed metafile. Items use coordinates in `0..width` and `0..height`, y down.
#[derive(Debug, Clone, PartialEq)]
pub struct Picture {
/// Size of the drawing in its own units: points for placeable WMF and EMF (from the frame), device
/// or logical units otherwise.
pub width: f32,
pub height: f32,
pub items: Vec<Item>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Item {
/// A filled and/or stroked path, in picture coordinates.
Path {
segs: Vec<Seg>,
fill: Option<[u8; 4]>,
/// Colour and width in picture units.
stroke: Option<([u8; 4], f32)>,
even_odd: bool,
},
/// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down.
Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec<u8> },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Seg {
Move(f32, f32),
Line(f32, f32),
Cubic(f32, f32, f32, f32, f32, f32),
Close,
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum Error {
#[error("not a WMF or EMF file")]
NotMetafile,
#[error("metafile header is truncated or invalid")]
BadHeader,
#[error("metafile has no readable records")]
NoRecords,
}
/// The picture frame in device units (`rect` = `[x, y, w, h]`) and, when the file states one, its
/// size in picture units.
pub(crate) struct Frame {
pub rect: [f64; 4],
pub size: Option<(f64, f64)>,
}
/// Parses a WMF (placeable or plain) or EMF file.
pub fn parse(bytes: &[u8]) -> Result<Picture, Error> {
let b = Bytes(bytes);
let (cv, frame) = if emf::is_emf(b) {
emf::parse(b)?
} else if wmf::is_wmf(b) {
wmf::parse(b)?
} else {
return Err(Error::NotMetafile);
};
Ok(build(cv.items, frame))
}
/// True when the bytes start like a WMF (placeable or plain header) or an EMF.
pub fn is_metafile(bytes: &[u8]) -> bool {
let b = Bytes(bytes);
emf::is_emf(b) || wmf::is_wmf(b)
}
/// Maps device-space output into the picture frame. Without a usable frame the frame is the bounding
/// box of the drawing, so the origin is still the top-left of what was drawn.
fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
let frame = frame.filter(|f| f.rect.iter().all(|v| v.is_finite()) && f.rect[2] > 0.0 && f.rect[3] > 0.0);
let (rect, size) = match frame {
Some(f) => (f.rect, f.size.unwrap_or((f.rect[2], f.rect[3]))),
None => match bbox(&raw) {
Some([x0, y0, x1, y1]) => ([x0, y0, x1 - x0, y1 - y0], (x1 - x0, y1 - y0)),
None => return Picture { width: 0.0, height: 0.0, items: Vec::new() },
},
};
let sx = if rect[2] > 0.0 { size.0 / rect[2] } else { 1.0 };
let sy = if rect[3] > 0.0 { size.1 / rect[3] } else { 1.0 };
let tf = move |p: P| ((p.0 - rect[0]) * sx, (p.1 - rect[1]) * sy);
let stroke_scale = (sx + sy) / 2.0;
// No stroke is wider than the picture's diagonal: anything wider is a mapping artefact.
let diag = size.0.hypot(size.1);
let items = raw.into_iter().filter_map(|r| match r {
Raw::Path { ops, fill, stroke, even_odd } => {
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
if segs.len() < 2 {
return None;
}
let stroke = stroke.and_then(|(c, w)| Some((c, f((w * stroke_scale).min(diag))?)));
Some(Item::Path { segs, fill, stroke, even_odd })
}
Raw::Bitmap { rect: r, img } => {
let (x, y) = tf((r[0], r[1]));
let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?];
Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px })
}
});
let items = items.collect();
Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items }
}
fn f(v: f64) -> Option<f32> {
let v = v as f32;
v.is_finite().then_some(v)
}
fn seg(op: &Op, tf: impl Fn(P) -> P) -> Option<Seg> {
let pt = |p: P| {
let (x, y) = tf(p);
Some((f(x)?, f(y)?))
};
Some(match *op {
Op::M(p) => {
let (x, y) = pt(p)?;
Seg::Move(x, y)
}
Op::L(p) => {
let (x, y) = pt(p)?;
Seg::Line(x, y)
}
Op::C(a, b, c) => {
let (x1, y1) = pt(a)?;
let (x2, y2) = pt(b)?;
let (x3, y3) = pt(c)?;
Seg::Cubic(x1, y1, x2, y2, x3, y3)
}
Op::Z => Seg::Close,
})
}
/// Bounding box `[x0, y0, x1, y1]` over the finite points of the output.
fn bbox(raw: &[Raw]) -> Option<[f64; 4]> {
let mut b: Option<[f64; 4]> = None;
let mut grow = |p: P| {
if p.0.is_finite() && p.1.is_finite() {
b = Some(match b {
None => [p.0, p.1, p.0, p.1],
Some([x0, y0, x1, y1]) => [x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)],
});
}
};
for r in raw {
match r {
Raw::Path { ops, .. } => {
for op in ops {
match *op {
Op::M(p) | Op::L(p) => grow(p),
Op::C(a, c, d) => {
grow(a);
grow(c);
grow(d);
}
Op::Z => {}
}
}
}
Raw::Bitmap { rect, .. } => {
grow((rect[0], rect[1]));
grow((rect[0] + rect[2], rect[1] + rect[3]));
}
}
}
b
}
+124
View File
@@ -0,0 +1,124 @@
//! Placing a picture on a target (a page or a device): the fitted transform, paths as kurbo paths in
//! target units and bitmaps as target rectangles. The rasteriser and the PDF writer only paint the result.
use kurbo::{Affine, BezPath, Rect, Shape};
use crate::{Item, Picture, Seg};
/// Largest bitmap side the renderers draw: within the PDF image limit and the rasteriser's 16-bit sides.
pub const MAX_BITMAP_SIDE: u32 = 30_000;
/// Placed coordinates past this (target units) are far off any page; such items are left out.
const MAX_COORD: f64 = 1e6;
/// Smallest placed bitmap side (target units), so the stretch onto it is never singular.
const MIN_BITMAP_SIDE: f64 = 1e-6;
/// One item of a placed picture, in target units.
pub enum PlacedItem {
/// A filled and/or stroked path. The stroke width is in target units; 0 is a cosmetic hairline that
/// the renderer draws at its minimum width.
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
/// `Picture::items[index]`, a bitmap, stretched into `rect`.
Bitmap { index: usize, rect: Rect },
}
impl Picture {
/// Whether the picture has a finite, positive size, so it can be fitted into a frame.
pub fn has_size(&self) -> bool {
self.width.is_finite() && self.height.is_finite() && self.width > 0.0 && self.height > 0.0
}
/// The transform that fits the picture into `r` with the crop fractions `crop` (left, top, right,
/// bottom) removed first: the visible part fills `r`. None for a degenerate size.
pub fn fit(&self, r: Rect, crop: [f32; 4]) -> Option<Affine> {
let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 0.95)) } else { 0.0 });
let (w, h) = (f64::from(self.width), f64::from(self.height));
let sx = r.width() / (w * (1.0 - cl - cr).max(0.05));
let sy = r.height() / (h * (1.0 - ct - cb).max(0.05));
(sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0)
.then(|| Affine::translate((r.x0 - cl * w * sx, r.y0 - ct * h * sy)) * Affine::scale_non_uniform(sx, sy))
}
/// The items placed into `r` with `crop` applied. Items that are not finite or reach past ±1e6
/// target units are left out (they are far off any page), as are bitmaps with no area. Stroke
/// widths are scaled to target units (along the mean axis scale, so a non-uniform fit keeps
/// strokes even).
pub fn place(&self, r: Rect, crop: [f32; 4]) -> Vec<PlacedItem> {
let Some(tf) = self.fit(r, crop) else { return Vec::new() };
let c = tf.as_coeffs();
let k = (c[0].abs() + c[3].abs()) / 2.0;
let mut out = Vec::with_capacity(self.items.len());
for (index, item) in self.items.iter().enumerate() {
match item {
Item::Path { segs, fill, stroke, even_odd } => {
let path = tf * bez(segs);
if in_range(path.bounding_box()) {
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd });
}
}
Item::Bitmap { rect: [x, y, w, h], .. } => {
let (x0, y0) = (f64::from(*x), f64::from(*y));
let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h)));
if in_range(rect) && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE {
out.push(PlacedItem::Bitmap { index, rect });
}
}
}
}
out
}
}
impl Item {
/// Takes a bitmap's pixels out of the item (its geometry stays) and returns them as (width, height,
/// RGBA) when a renderer can draw them: both sides in 1..=[`MAX_BITMAP_SIDE`] and exactly that many
/// pixels. None for paths and for bitmaps that cannot be drawn.
pub fn take_pixels(&mut self) -> Option<(u32, u32, Vec<u8>)> {
let Item::Bitmap { width, height, rgba, .. } = self else { return None };
let (w, h, px) = (*width, *height, std::mem::take(rgba));
let sides = (1..=MAX_BITMAP_SIDE).contains(&w) && (1..=MAX_BITMAP_SIDE).contains(&h);
(sides && px.len() == (w as usize).saturating_mul(h as usize).saturating_mul(4)).then_some((w, h, px))
}
}
/// Segments as a kurbo path. A segment after a close opens a new subpath at the last start point.
fn bez(segs: &[Seg]) -> BezPath {
let mut p = BezPath::new();
let mut start = (0.0, 0.0);
let mut open = false;
for s in segs {
match *s {
Seg::Move(x, y) => {
start = (f64::from(x), f64::from(y));
p.move_to(start);
open = true;
}
Seg::Line(x, y) => {
if !open {
p.move_to(start);
}
p.line_to((f64::from(x), f64::from(y)));
open = true;
}
Seg::Cubic(a, b, c, d, e, f) => {
if !open {
p.move_to(start);
}
p.curve_to((f64::from(a), f64::from(b)), (f64::from(c), f64::from(d)), (f64::from(e), f64::from(f)));
open = true;
}
Seg::Close => {
if open {
p.close_path();
}
open = false;
}
}
}
p
}
/// Whether a placed box is finite and within ±[`MAX_COORD`].
fn in_range(b: Rect) -> bool {
[b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() < MAX_COORD)
}
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
//! WMF reader (MS-WMF). A file is an optional 22-byte placeable header, a 9-word standard header, then
//! records of (size in 16-bit words, function, parameters). Parameters are read at byte 6 of each record.
use crate::bytes::Bytes;
use crate::canvas::{Brush, Canvas, Obj, Pen, colorref};
use crate::dib;
use crate::geom::{self, ArcKind, P};
use crate::{Error, Frame, MAX_PATH_OPS};
const PLACEABLE_KEY: u32 = 0x9AC6_CDD7;
const DEFAULT_INCH: f64 = 1440.0;
pub(crate) fn is_wmf(b: Bytes) -> bool {
if b.u32(0) == Some(PLACEABLE_KEY) {
return true;
}
matches!(b.u16(0), Some(1 | 2)) && b.u16(2) == Some(9) && matches!(b.u16(4), Some(0x100 | 0x300))
}
/// Reads all records. Returns the drawing state and the picture frame, or an error when no record
/// could be read at all.
pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
let placeable = input.u32(0) == Some(PLACEABLE_KEY);
// Placeable bounds: logical corners (left, top, right, bottom) and their size in points.
let (hdr, bounds) = if placeable {
let [l, t, r, b] = [6, 8, 10, 12].map(|o| input.i16(o).map(f64::from));
let (Some(l), Some(t), Some(r), Some(b)) = (l, t, r, b) else { return Err(Error::BadHeader) };
let inch = input.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from);
let (w, h) = (r - l, b - t);
let bounds = (w > 0.0 && h > 0.0).then_some(([l, t, r, b], (w * 72.0 / inch, h * 72.0 / inch)));
(22, bounds)
} else {
(0, None)
};
let hsize = input.u16(hdr + 2).ok_or(Error::BadHeader)? as usize;
if hsize < 9 {
return Err(Error::BadHeader);
}
let mut pos = hdr.checked_add(hsize.checked_mul(2).ok_or(Error::BadHeader)?).ok_or(Error::BadHeader)?;
// Played into a target like Word does: the placeable bounds are the first window, the target (the
// picture size) the viewport. A plain WMF's target is its first window.
let mut cv = Canvas::new();
if let Some((c, size)) = bounds {
cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size);
}
let mut player_viewport = bounds.is_some();
let mut parsed = 0usize;
while !cv.full() {
let (Some(words), Some(func)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u16(o))) else { break };
let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { break };
let Some(rec) = input.slice(pos, len) else { break };
if func == 0 {
break;
}
record(&mut cv, func, Bytes(rec));
if func == 0x020C && cv.window_set && !player_viewport && !cv.viewport_set {
let we = cv.map.window_ext();
cv.play_into(None, (we.0.abs(), we.1.abs()));
player_viewport = true;
}
parsed += 1;
let Some(next) = pos.checked_add(len) else { break };
pos = next;
}
if parsed == 0 {
return Err(Error::NoRecords);
}
let frame = match bounds {
// Played into its target: the frame is the target.
Some((_, size)) if !cv.viewport_set && cv.map.uses_extents() => Some(Frame { rect: [0.0, 0.0, size.0, size.1], size: Some(size) }),
// The file set its own viewport, so the drawing is in its device units and the logical bounds
// go through the final mapping.
Some((c, size)) => {
let (a, b) = (cv.map.device((c[0], c[1])), cv.map.device((c[2], c[3])));
let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()];
(rect[2] > 0.0 && rect[3] > 0.0).then_some(Frame { rect, size: Some(size) })
}
None if player_viewport && !cv.viewport_set && cv.map.uses_extents() => {
let ve = cv.map.viewport_ext();
Some(Frame { rect: [0.0, 0.0, ve.0, ve.1], size: None })
}
None if cv.window_set => Some(Frame { rect: cv.map.window_rect(), size: None }),
None => None,
};
Ok((cv, frame))
}
fn record(cv: &mut Canvas, func: u16, r: Bytes) {
if cv.full() {
return;
}
let _ = match func {
0x0103 => r.u16(6).map(|m| cv.set_map_mode(u32::from(m))),
0x0106 => r.u16(6).map(|m| cv.set_polyfill(u32::from(m))),
0x0104 => r.u16(6).map(|m| cv.set_rop2(u32::from(m))),
// Y is stored before X in these records.
0x020B => xy16(r, 6).map(|p| cv.set_window_org(p)),
0x020C => xy16(r, 6).map(|p| cv.set_window_ext(p)),
0x020D => xy16(r, 6).map(|p| cv.set_viewport_org(p)),
0x020E => xy16(r, 6).map(|p| cv.set_viewport_ext(p)),
0x001E => {
cv.save_dc();
Some(())
}
0x0127 => r.i16(6).map(|n| cv.restore_dc(i32::from(n))),
0x0214 => xy16(r, 6).map(|p| cv.move_to(p)),
0x0213 => xy16(r, 6).map(|p| cv.line_to(p)),
0x0325 => counted_points(r).map(|pts| cv.poly(&pts, false, false, true)),
0x0324 => counted_points(r).map(|pts| cv.poly(&pts, true, true, true)),
0x0538 => polypolygon(r).map(|ops| cv.shape(&ops, true, true)),
0x041B => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)),
0x061C => {
let [ew, eh] = [r.i16(8), r.i16(6)];
let (Some(ew), Some(eh)) = (ew, eh) else { return };
rect16(r, 10).map(|[l, t, rr, b]| {
cv.shape(&geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true);
})
}
0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
0x0817 | 0x081A | 0x0830 => arc16(r).map(|(bounds, s, e)| {
let kind = match func {
0x0817 => ArcKind::Arc,
0x081A => ArcKind::Pie,
_ => ArcKind::Chord,
};
let [l, t, rr, b] = bounds;
cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true);
}),
0x02FA => create_pen(cv, r),
0x02FC => create_brush(cv, r),
// Fonts, palettes, pattern brushes and regions take a handle slot too, so later SELECTOBJECT
// handles count them; selecting one draws nothing.
0x02FB | 0x00F7 | 0x01F9 | 0x0142 | 0x06FF => {
cv.add_object(None, Obj::Other);
Some(())
}
0x012D => r.u16(6).map(|h| cv.select(wmf_handle(h))),
0x01F0 => r.u16(6).map(|h| cv.delete_object(u32::from(h))),
// STRETCHDIB: rop(4) usage(2) srcH srcW ySrc xSrc destH destW yDest xDest, then the DIB.
0x0F43 => {
let rop = r.u32(6).unwrap_or(0);
dib_blit(cv, r, 28, rop, [i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], [i16s(r, 18), i16s(r, 16), i16s(r, 14), i16s(r, 12)])
}
0x0B41 => dib_stretchblt(cv, r),
0x0940 => dib_bitblt(cv, r),
_ => Some(()),
};
}
/// A 16-bit WMF handle as a canvas object index: stock objects are `0x8000 | index`, which the
/// canvas reads with the 32-bit stock flag.
fn wmf_handle(h: u16) -> u32 {
let h = u32::from(h);
if h & 0x8000 != 0 { (h & 0x7FFF) | 0x8000_0000 } else { h }
}
/// An i16 field as f64; 0 past the end.
fn i16s(r: Bytes, off: usize) -> f64 {
r.i16(off).map_or(0.0, f64::from)
}
/// CREATEPENINDIRECT: style, width (x, y), colour. Only the width's x component is used.
fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> {
let style = r.u16(6)?;
let width = r.i16(8)?;
let color = r.u32(12)?;
cv.add_object(None, Obj::Pen(Pen { style: u32::from(style), width: f64::from(width), color: colorref(color) }));
Some(())
}
/// CREATEBRUSHINDIRECT: style, colour, hatch (hatch is not drawn).
fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
let style = r.u16(6)?;
let color = r.u32(8)?;
cv.add_object(None, Obj::Brush(Brush { style: u32::from(style), color: colorref(color) }));
Some(())
}
/// (x, y) from a Y-then-X pair stored at `off`.
fn xy16(r: Bytes, off: usize) -> Option<P> {
Some((f64::from(r.i16(off + 2)?), f64::from(r.i16(off)?)))
}
/// Rectangle stored as bottom, right, top, left (the order MS-WMF uses for RECTANGLE and ELLIPSE).
fn rect16(r: Bytes, off: usize) -> Option<[f64; 4]> {
let (b, rr, t, l) = (r.i16(off)?, r.i16(off + 2)?, r.i16(off + 4)?, r.i16(off + 6)?);
Some([f64::from(l), f64::from(t), f64::from(rr), f64::from(b)])
}
/// ARC, PIE and CHORD: end Y, end X, start Y, start X, then the bounding rectangle (bottom, right, top, left).
fn arc16(r: Bytes) -> Option<([f64; 4], P, P)> {
let end = xy16(r, 6)?;
let start = xy16(r, 10)?;
let bounds = rect16(r, 14)?;
Some((bounds, start, end))
}
/// A count of 16-bit points, then the points (POLYLINE, POLYGON).
fn counted_points(r: Bytes) -> Option<Vec<P>> {
let n = r.u16(6)? as usize;
r.tail(8)?.points(0, n, false)
}
/// POLYPOLYGON: polygon count, one 16-bit point count per polygon, then all points.
fn polypolygon(r: Bytes) -> Option<Vec<geom::Op>> {
let n = r.u16(6)? as usize;
let mut counts = Vec::with_capacity(n);
for i in 0..n {
counts.push(r.u16(i.checked_mul(2)?.checked_add(8)?)? as usize);
}
let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?;
if total > MAX_PATH_OPS {
return None;
}
let pts = r.tail(n.checked_mul(2)?.checked_add(8)?)?.points(0, total, false)?;
Some(geom::subpaths(&pts, &counts, true))
}
/// Whether a DIBBITBLT or DIBSTRETCHBLT record carries a bitmap: without one it is exactly
/// `(function >> 8) + 3` words long (MS-WMF 2.3.1.2, 2.3.1.3).
fn has_bitmap(func: u16, r: Bytes) -> bool {
r.0.len() / 2 > usize::from(func >> 8) + 3
}
/// META_DIBBITBLT: rop(4), ySrc, xSrc, then height, width, yDest, xDest and the DIB at 22. The
/// bitmap-less form has a reserved word before the height, so its fields sit 2 bytes later; it, or
/// a source-less raster operation, fills the destination.
fn dib_bitblt(cv: &mut Canvas, r: Bytes) -> Option<()> {
let rop = r.u32(6)?;
if !has_bitmap(0x0940, r) {
cv.fill_rop([i16s(r, 22), i16s(r, 20), i16s(r, 18), i16s(r, 16)], rop);
return Some(());
}
let dst = [i16s(r, 20), i16s(r, 18), i16s(r, 16), i16s(r, 14)];
if Canvas::sourceless(rop) {
cv.fill_rop(dst, rop);
return Some(());
}
let src = [i16s(r, 12), i16s(r, 10), dst[2], dst[3]];
dib_blit(cv, r, 22, rop, dst, src)
}
/// META_DIBSTRETCHBLT: rop(4), srcH, srcW, ySrc, xSrc, then destH, destW, yDest, xDest and the DIB at
/// 26. The bitmap-less form has a reserved word after xSrc, so the destination sits 2 bytes later;
/// it, or a source-less raster operation, fills the destination.
fn dib_stretchblt(cv: &mut Canvas, r: Bytes) -> Option<()> {
let rop = r.u32(6)?;
if !has_bitmap(0x0B41, r) {
cv.fill_rop([i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], rop);
return Some(());
}
let dst = [i16s(r, 24), i16s(r, 22), i16s(r, 20), i16s(r, 18)];
if Canvas::sourceless(rop) {
cv.fill_rop(dst, rop);
return Some(());
}
let src = [i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)];
dib_blit(cv, r, 26, rop, dst, src)
}
/// A DIB at `at`, stretched from source rectangle `src` into `dst` (both in logical units, pixels for `src`).
/// The raster operation `rop` decides how the pixels combine (see [`dib::prepare`]).
fn dib_blit(cv: &mut Canvas, r: Bytes, at: usize, rop: u32, dst: [f64; 4], src: [f64; 4]) -> Option<()> {
let dib = r.tail(at)?;
let n = dib::info_len(dib)?;
let info = dib.slice(0, n)?;
let bits = dib.tail(n)?;
let mut img = dib::decode(Bytes(info), bits, false)?;
dib::prepare(&mut img, dib::Blend::Rop(rop));
let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?;
cv.bitmap(dst, img);
Some(())
}
+1
View File
@@ -512,6 +512,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen
first_slide_number: 1,
next_id: next.max(256),
embedded_fonts: vec![],
table_styles: vec![],
raw_ext: None,
};
if title_slide {
+3
View File
@@ -798,6 +798,9 @@ pub struct Presentation {
pub next_id: u32,
/// Embedded fonts (family names; bytes in `media`).
pub embedded_fonts: Vec<(String, MediaId)>,
/// Table styles defined in the file (`ppt/tableStyles.xml`).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub table_styles: Vec<table::TableStyleDef>,
/// Unmodelled presentation-level XML kept for round-trip.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_ext: Option<String>,
+1
View File
@@ -223,6 +223,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
columns: Some(1),
col_spacing: Some(0.0),
warp: None,
warp_adj: Vec::new(),
rtl_col: Some(false),
upright: Some(false),
});
+109 -1
View File
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
use crate::style::{Fill, Line};
use crate::style::{ColorRef, Fill, Line};
use crate::text::{Anchor, TextBody};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -55,6 +55,9 @@ pub struct Table {
pub last_col: bool,
pub band_row: bool,
pub band_col: bool,
/// `a:tblPr` fill: a fill behind the whole table (over the style's own background).
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<Fill>,
}
impl Default for Table {
@@ -69,6 +72,7 @@ impl Default for Table {
last_col: false,
band_row: true,
band_col: false,
background: None,
}
}
}
@@ -199,6 +203,110 @@ impl Table {
}
}
/// A table style defined in the file (`ppt/tableStyles.xml`), as PowerPoint writes the built-in
/// styles a deck uses: how each part of a table (whole table, banded rows and columns, first and
/// last rows and columns, corner cells) is filled, bordered and lettered.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TableStyleDef {
/// The style's GUID (`a:tableStyleId` on tables).
pub id: String,
pub name: String,
pub parts: Vec<(TablePart, TablePartStyle)>,
/// `a:tblBg`: a fill behind the whole table (only `fill` and `fill_ref` are used).
pub background: Option<TablePartStyle>,
/// The definition as read, written back unchanged.
#[serde(skip_serializing_if = "String::is_empty")]
pub raw: String,
}
impl TableStyleDef {
pub fn part(&self, p: TablePart) -> Option<&TablePartStyle> {
self.parts.iter().find(|(k, _)| *k == p).map(|(_, s)| s)
}
}
/// The parts of a table style, in the order they apply (later ones win).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TablePart {
WholeTbl,
Band1H,
Band2H,
Band1V,
Band2V,
LastCol,
FirstCol,
LastRow,
FirstRow,
SeCell,
SwCell,
NeCell,
NwCell,
}
impl TablePart {
pub const ALL: [TablePart; 13] = [
TablePart::WholeTbl,
TablePart::Band1H,
TablePart::Band2H,
TablePart::Band1V,
TablePart::Band2V,
TablePart::LastCol,
TablePart::FirstCol,
TablePart::LastRow,
TablePart::FirstRow,
TablePart::SeCell,
TablePart::SwCell,
TablePart::NeCell,
TablePart::NwCell,
];
pub fn xml(self) -> &'static str {
match self {
TablePart::WholeTbl => "wholeTbl",
TablePart::Band1H => "band1H",
TablePart::Band2H => "band2H",
TablePart::Band1V => "band1V",
TablePart::Band2V => "band2V",
TablePart::LastCol => "lastCol",
TablePart::FirstCol => "firstCol",
TablePart::LastRow => "lastRow",
TablePart::FirstRow => "firstRow",
TablePart::SeCell => "seCell",
TablePart::SwCell => "swCell",
TablePart::NeCell => "neCell",
TablePart::NwCell => "nwCell",
}
}
pub fn from_xml(s: &str) -> Option<TablePart> {
TablePart::ALL.into_iter().find(|p| p.xml() == s)
}
}
/// One part of a table style. A theme reference (`a:fillRef`, `a:lnRef`) is kept as the theme's
/// style index with its colour.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TablePartStyle {
pub fill: Option<Fill>,
pub fill_ref: Option<(u32, ColorRef)>,
/// Left, right, top, bottom, inside horizontal, inside vertical.
pub borders: [Option<TableBorder>; 6],
pub text_color: Option<ColorRef>,
pub bold: Option<bool>,
pub italic: Option<bool>,
}
/// A border of a table style part: a line, or a theme line style reference.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TableBorder {
Line { line: Line },
Ref { idx: u32, color: ColorRef },
}
/// Built-in table styles: (id, label). Styles are generated from the theme accents.
pub fn table_styles() -> Vec<(String, String)> {
let mut v = vec![("none".to_string(), "No Style, No Grid".to_string()), ("grid".to_string(), "No Style, Table Grid".to_string())];
+11
View File
@@ -98,6 +98,10 @@ pub struct BodyProps {
pub col_spacing: Option<f64>,
/// WordArt transform (`prstTxWarp`), e.g. `textArchUp`.
pub warp: Option<String>,
/// The warp's adjust values (`prstTxWarp/avLst`: `adj` or `adj1`, `adj2`…, raw guide values);
/// empty = the preset's defaults.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warp_adj: Vec<f64>,
pub rtl_col: Option<bool>,
/// Keep text upright when the shape rotates.
pub upright: Option<bool>,
@@ -119,6 +123,7 @@ impl BodyProps {
self.col_spacing = self.col_spacing.or(p.col_spacing);
if self.warp.is_none() {
self.warp.clone_from(&p.warp);
self.warp_adj.clone_from(&p.warp_adj);
}
self.rtl_col = self.rtl_col.or(p.rtl_col);
self.upright = self.upright.or(p.upright);
@@ -223,6 +228,10 @@ pub struct ParaProps {
pub rtl: Option<bool>,
pub font_align: Option<String>,
pub east_asian_line_break: Option<bool>,
/// `latinLnBrk`: Latin words may break inside (after `/`, `\`); off by default.
pub latin_line_break: Option<bool>,
/// `hangingPunct`: closing East Asian punctuation may hang past the end of a line.
pub hanging_punct: Option<bool>,
}
impl ParaProps {
@@ -253,6 +262,8 @@ impl ParaProps {
self.font_align.clone_from(&p.font_align);
}
self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break);
self.latin_line_break = self.latin_line_break.or(p.latin_line_break);
self.hanging_punct = self.hanging_punct.or(p.hanging_punct);
}
}
+35 -1
View File
@@ -321,13 +321,45 @@ pub fn body_pr(b: &El) -> BodyProps {
}
"spAutoFit" => p.autofit = Some(AutoFit::Shape),
"noAutofit" => p.autofit = Some(AutoFit::None),
"prstTxWarp" => p.warp = e.attr("prst").filter(|w| *w != "textNoShape").map(String::from),
"prstTxWarp" => {
p.warp = e.attr("prst").filter(|w| *w != "textNoShape" && !w.is_empty()).map(String::from);
p.warp_adj = p.warp.as_deref().map(|w| warp_adjusts(w, e)).unwrap_or_default();
}
_ => {}
}
}
p
}
/// `prstTxWarp/avLst` guides `adj` / `adj1`… as raw values by index (empty when none is given);
/// slots the file leaves out keep the preset's default.
fn warp_adjusts(preset: &str, e: &El) -> Vec<f64> {
let mut adj: Vec<f64> = Vec::new();
let Some(av) = e.child("avLst") else { return adj };
let defaults = deckcraft_geom::warp::adjust_defaults(preset);
for gd in av.children_named("gd").take(16) {
let n = gd.attr("name").unwrap_or("");
let idx = if n == "adj" {
0
} else if let Some(k) = n.strip_prefix("adj").and_then(|k| k.parse::<usize>().ok()).filter(|k| (1..=8).contains(k)) {
k - 1
} else {
continue;
};
let Some(v) = gd.attr("fmla").and_then(|f| f.trim().strip_prefix("val")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
else {
continue;
};
while adj.len() <= idx {
adj.push(defaults.get(adj.len()).copied().unwrap_or(0.0));
}
if let Some(slot) = adj.get_mut(idx) {
*slot = v.clamp(-1.0e9, 1.0e9);
}
}
adj
}
fn spacing(e: &El) -> Option<Spacing> {
if let Some(p) = e.child("spcPct") {
return Some(Spacing::Pct(pct(p, "val").unwrap_or(1.0).clamp(0.0, 100.0)));
@@ -345,6 +377,8 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
rtl: p.bool("rtl"),
font_align: p.attr("fontAlgn").map(String::from),
east_asian_line_break: p.bool("eaLnBrk"),
latin_line_break: p.bool("latinLnBrk"),
hanging_punct: p.bool("hangingPunct"),
default_tab: pt(p, "defTabSz"),
..Default::default()
};
+73
View File
@@ -30,6 +30,8 @@ pub struct Imp<'a> {
pub next: u32,
pub slide_by_part: HashMap<String, SlideId>,
pub custom_show_names: HashMap<String, String>,
/// GUIDs (upper case) of the table styles the file defines.
pub table_style_ids: Vec<String>,
}
impl Imp<'_> {
@@ -230,6 +232,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
next: max_id.max(1),
slide_by_part: HashMap::new(),
custom_show_names: HashMap::new(),
table_style_ids: Vec::new(),
};
// Slide ids first so hyperlinks anywhere can point at slides.
let mut slide_ids: Vec<(u32, String, SlideId)> = vec![];
@@ -269,6 +272,13 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
if let Some(dts) = pres_el.child("defaultTextStyle") {
p.default_text_style = dml::list_style(&mut imp, &pres_part, dts);
}
if let Some(r) = pres_part.rels.first_of("tableStyles")
&& let Some(d) = load(&pkg, &r.target)
{
let part = Part::new(&pkg, &r.target, &d);
p.table_styles = table_styles(&mut imp, &part, &d.root);
imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect();
}
// Masters and layouts.
let mut layout_by_part: HashMap<String, LayoutId> = HashMap::new();
@@ -505,6 +515,69 @@ fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut
}
}
/// `a:tblStyleLst`: the table styles the file defines.
fn table_styles(imp: &mut Imp, part: &Part, root: &El) -> Vec<deckcraft_model::table::TableStyleDef> {
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
root.children_named("tblStyle")
.take(256)
.filter_map(|st| {
let id = st.attr("styleId")?.to_ascii_uppercase();
let mut def = TableStyleDef { id, name: st.attr("styleName").unwrap_or("").to_string(), raw: part.keep(st), ..Default::default() };
if let Some(bg) = st.child("tblBg") {
let mut ps = TablePartStyle::default();
if let Some(f) = bg.elements().find(|e| e.local().ends_with("Fill")) {
ps.fill = dml::fill_el(imp, part, f);
}
if let Some(fr) = bg.child("fillRef") {
ps.fill_ref = Some((
fr.u32("idx").unwrap_or(0),
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
));
}
def.background = Some(ps);
}
for el in st.elements() {
let Some(kind) = TablePart::from_xml(el.local()) else { continue };
let mut ps = TablePartStyle::default();
if let Some(tx) = el.child("tcTxStyle") {
ps.text_color = dml::color(tx);
ps.bold = tx.attr("b").map(|v| v == "on");
ps.italic = tx.attr("i").map(|v| v == "on");
}
if let Some(tc) = el.child("tcStyle") {
if let Some(f) = tc.child("fill").and_then(|f| f.elements().next()) {
ps.fill = dml::fill_el(imp, part, f);
}
if let Some(fr) = tc.child("fillRef") {
ps.fill_ref = Some((
fr.u32("idx").unwrap_or(0),
dml::color(fr).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
));
}
if let Some(bd) = tc.child("tcBdr") {
for (i, side) in ["left", "right", "top", "bottom", "insideH", "insideV"].iter().enumerate() {
let Some(b) = bd.child(side) else { continue };
let border = if let Some(ln) = b.child("ln") {
Some(TableBorder::Line { line: dml::line(imp, part, ln) })
} else {
b.child("lnRef").map(|r| TableBorder::Ref {
idx: r.u32("idx").unwrap_or(0),
color: dml::color(r).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
})
};
if let Some(slot) = ps.borders.get_mut(i) {
*slot = border;
}
}
}
}
def.parts.push((kind, ps));
}
Some(def)
})
.collect()
}
fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option<Background> {
let bg = csld.child("bg")?;
if let Some(pr) = bg.child("bgPr") {
+6 -1
View File
@@ -394,6 +394,7 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) ->
fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
let mut t = Table { style: "none".into(), first_row: false, band_row: false, ..Default::default() };
if let Some(pr) = tbl.child("tblPr") {
t.background = pr.elements().find(|e| e.local().ends_with("Fill") && e.local() != "noFill").and_then(|f| dml::fill_el(imp, part, f));
t.first_row = pr.bool("firstRow").unwrap_or(false);
t.first_col = pr.bool("firstCol").unwrap_or(false);
t.last_row = pr.bool("lastRow").unwrap_or(false);
@@ -401,7 +402,11 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
t.band_row = pr.bool("bandRow").unwrap_or(false);
t.band_col = pr.bool("bandCol").unwrap_or(false);
if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) {
t.style = crate::tables::style_from_guid(id.trim());
// A style the file defines is drawn from its definition (by its GUID).
// DeckCraft's own styles (written with GUIDs derived from their ids) map back to them.
let up = id.trim().to_ascii_uppercase();
t.style =
if imp.table_style_ids.contains(&up) && !crate::tables::is_own_guid(&up) { up } else { crate::tables::style_from_guid(id.trim()) };
}
}
t.cols = tbl
+16 -3
View File
@@ -47,6 +47,12 @@ fn own_guid(style: &str) -> String {
guid(&format!("deckcraft-table-style:{style}"))
}
/// Is `g` the GUID DeckCraft writes for one of its own styles?
pub fn is_own_guid(g: &str) -> bool {
let up = g.to_ascii_uppercase();
deckcraft_model::table::table_styles().iter().any(|(id, _)| own_guid(id) == up)
}
/// Table style GUID → our style id (or the GUID itself when unknown).
pub fn style_from_guid(g: &str) -> String {
let up = g.to_ascii_uppercase();
@@ -198,8 +204,9 @@ fn style_def(w: &mut W, id: &str, name: &str) {
w.close("a:tblStyle");
}
/// `ppt/tableStyles.xml` with definitions for the styles in `used`.
pub fn table_styles_xml(used: &[String]) -> Vec<u8> {
/// `ppt/tableStyles.xml` with definitions for the styles in `used`: ours, generated, and those the
/// opened file defined (`defs`), written back as they were read.
pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableStyleDef]) -> Vec<u8> {
let mut w = W::new();
w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"));
let names = deckcraft_model::table::table_styles();
@@ -209,6 +216,12 @@ pub fn table_styles_xml(used: &[String]) -> Vec<u8> {
continue;
}
done.push(s);
if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s))
&& crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle"))
{
w.raw(&d.raw);
continue;
}
let (_, define) = guid_for_style(s);
if !define {
continue;
@@ -231,7 +244,7 @@ mod tests {
"nwCell",
];
let used: Vec<String> = deckcraft_model::table::table_styles().into_iter().map(|(id, _)| id).collect();
let d = crate::xml::parse(&table_styles_xml(&used)).unwrap();
let d = crate::xml::parse(&table_styles_xml(&used, &[])).unwrap();
let mut n = 0;
for st in d.root.children_named("tblStyle") {
n += 1;
+15 -1
View File
@@ -392,7 +392,19 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
w.open("a:bodyPr", a);
if let Some(p) = warp {
w.open("a:prstTxWarp", A::new().a("prst", p));
w.empty0("a:avLst");
let adj: Vec<(usize, i64)> =
b.warp_adj.iter().take(8).enumerate().filter(|(_, v)| v.is_finite()).map(|(i, v)| (i, v.round().clamp(-2.0e9, 2.0e9) as i64)).collect();
if adj.is_empty() {
w.empty0("a:avLst");
} else {
let single = deckcraft_geom::warp::adjust_count(p) <= 1 && b.warp_adj.len() == 1;
w.open0("a:avLst");
for (i, v) in adj {
let n = if single { "adj".to_string() } else { format!("adj{}", i + 1) };
w.empty("a:gd", A::new().a("name", n).a("fmla", format!("val {v}")));
}
w.close("a:avLst");
}
w.close("a:prstTxWarp");
}
match b.autofit {
@@ -432,6 +444,8 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
.o("defTabSz", p.default_tab.map(|v| emu(v).max(0)))
.b("rtl", p.rtl)
.b("eaLnBrk", p.east_asian_line_break)
.b("latinLnBrk", p.latin_line_break)
.b("hangingPunct", p.hanging_punct)
.o("fontAlgn", p.font_align.as_deref().filter(|f| matches!(*f, "auto" | "t" | "ctr" | "base" | "b")));
let has_kids = p.line_spacing.is_some()
|| p.space_before.is_some()
+1 -1
View File
@@ -512,7 +512,7 @@ pub fn export(p: &Presentation) -> Result<Vec<u8>, PptxError> {
pres.rels.add("tableStyles", "tableStyles.xml", false);
x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p));
x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml());
let ts = crate::tables::table_styles_xml(&x.table_styles);
let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles);
x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts);
let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids);
x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml);
+3
View File
@@ -494,6 +494,9 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
.t("bandRow", t.band_row)
.t("bandCol", t.band_col),
);
if let Some(f) = &t.background {
dml::fill(w, x, o, f);
}
w.elt("a:tableStyleId", &guid);
w.close("a:tblPr");
w.open0("a:tblGrid");
+91
View File
@@ -766,3 +766,94 @@ fn a_picture_duotone_round_trips() {
assert!(matches!(d.base, deckcraft_model::ColorBase::Scheme { slot: SchemeSlot::Accent1 }));
assert!(matches!(l.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(250, 240, 200)));
}
#[test]
fn line_break_options_round_trip() {
let mut p = defaults::new_presentation(None);
let first = p.slides[0].id;
let s = p.slide_mut(first).expect("slide");
let t = s.shapes[0].text.insert(TextBody::from_text("x"));
t.paragraphs[0].props.latin_line_break = Some(true);
t.paragraphs[0].props.hanging_punct = Some(false);
let q = round(&p);
let props = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].props;
assert_eq!(props.latin_line_break, Some(true));
assert_eq!(props.hanging_punct, Some(false));
}
#[test]
fn a_table_fill_round_trips() {
let mut p = defaults::new_presentation(None);
let mut t = Table::new(2, 2, 200.0, 40.0);
t.background = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 112, 192))));
let mut sh = shape(&mut p, "Filled table", 10.0, 10.0, 200.0, 80.0);
sh.kind = deckcraft_model::ShapeKind::Table(t);
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(sh);
let q = round(&p);
let deckcraft_model::ShapeKind::Table(t) = &by_name(&q.slides[0], "Filled table").kind else { panic!("table") };
assert!(
matches!(&t.background, Some(Fill::Solid { color }) if matches!(color.base, deckcraft_model::ColorBase::Rgb { rgb } if rgb == Rgba::rgb(0, 112, 192)))
);
}
#[test]
fn wordart_warps_and_their_adjusts_round_trip() {
let mut p = Presentation::default();
let cases: [(&str, &str, Vec<f64>); 5] = [
("Arch pour", "textArchUpPour", vec![13_500_000.0, 30_000.0]),
("Wave", "textWave1", vec![18_000.0, -5_000.0]),
("Inflate", "textInflate", vec![30_000.0]),
("Arch", "textArchUp", vec![]),
("Ring", "textRingInside", vec![75_000.0]),
];
let mut shapes = vec![];
for (i, (name, warp, adj)) in cases.iter().enumerate() {
let mut s = shape(&mut p, name, 10.0, 10.0 + 50.0 * i as f64, 300.0, 40.0);
let mut body = TextBody::from_text("WordArt");
body.body.warp = Some((*warp).into());
body.body.warp_adj = adj.clone();
s.text = Some(body);
shapes.push(s);
}
Arc::make_mut(&mut p.slides[0]).shapes = shapes;
let q = round(&p);
for (name, warp, adj) in &cases {
let b = &by_name(&q.slides[0], name).text.as_ref().expect("text").body;
assert_eq!(b.warp.as_deref(), Some(*warp), "{name}");
assert_eq!(&b.warp_adj, adj, "{name}");
}
// The single-adjust preset is written with the guide name `adj`, two-adjust ones with
// `adj1`/`adj2`, and a preset with no adjusts given gets an empty list.
let bytes = deckcraft_pptx::export(&p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").expect("slide"), &mut xml).expect("read");
assert!(xml.contains(r#"<a:prstTxWarp prst="textInflate"><a:avLst><a:gd name="adj" fmla="val 30000"/></a:avLst></a:prstTxWarp>"#), "{xml}");
assert!(xml.contains(r#"<a:gd name="adj1" fmla="val 13500000"/><a:gd name="adj2" fmla="val 30000"/>"#));
assert!(xml.contains(r#"<a:prstTxWarp prst="textArchUp"><a:avLst/></a:prstTxWarp>"#));
// A file that gives only `adj2` keeps the preset's default for `adj1`; hostile values are
// read as numbers or dropped.
let edited = xml
.replace(r#"<a:gd name="adj1" fmla="val 13500000"/>"#, "")
.replace(r#"<a:gd name="adj" fmla="val 30000"/>"#, r#"<a:gd name="adj" fmla="val NaN"/>"#);
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut f = zip.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
std::io::Read::read_to_end(&mut f, &mut data).expect("read");
if name == "ppt/slides/slide1.xml" {
data = edited.clone().into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
std::io::Write::write_all(&mut out, &data).expect("write");
}
let bytes = out.finish().expect("finish").into_inner();
let q = deckcraft_pptx::import(&bytes).expect("import");
let pour = &by_name(&q.slides[0], "Arch pour").text.as_ref().expect("text").body;
assert_eq!(pour.warp_adj, vec![10_800_000.0, 30_000.0]);
let inflate = &by_name(&q.slides[0], "Inflate").text.as_ref().expect("text").body;
assert_eq!(inflate.warp.as_deref(), Some("textInflate"));
assert!(inflate.warp_adj.is_empty());
}
+84
View File
@@ -0,0 +1,84 @@
//! Fills and styles read from decks written by other tools.
use std::io::{Cursor, Read, Write};
use deckcraft_geom::Xfrm;
use deckcraft_model::style::Fill;
use deckcraft_model::{Shape, ShapeId, defaults};
const STYLE_GUID: &str = "{ABCDEF01-2345-4678-9ABC-DEF012345678}";
/// A table style definition as PowerPoint writes one: red table background, see-through white
/// bands, 2 pt blue outer borders and no inside lines.
fn style_xml() -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{STYLE_GUID}"><a:tblStyle styleId="{STYLE_GUID}" styleName="Probe Style"><a:tblBg><a:fillRef idx="1"><a:srgbClr val="FF0000"/></a:fillRef></a:tblBg><a:wholeTbl><a:tcTxStyle b="on"><a:srgbClr val="00FF00"/></a:tcTxStyle><a:tcStyle><a:tcBdr><a:left><a:ln w="25400"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln></a:left><a:insideH><a:ln><a:noFill/></a:ln></a:insideH></a:tcBdr><a:fill><a:noFill/></a:fill></a:tcStyle></a:wholeTbl><a:band1H><a:tcStyle><a:tcBdr/><a:fill><a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="20000"/></a:srgbClr></a:solidFill></a:fill></a:tcStyle></a:band1H></a:tblStyle></a:tblStyleLst>"#
)
}
/// An exported deck with a 3x2 table using the probe style.
fn table_deck() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let first = p.slides[0].id;
let mut t = deckcraft_model::Table::new(3, 2, 300.0, 30.0);
t.style = "medium2-accent1".into();
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
name: "Styled".into(),
xfrm: Some(Xfrm::new(10.0, 10.0, 300.0, 90.0)),
kind: deckcraft_model::ShapeKind::Table(t),
..Default::default()
});
let bytes = deckcraft_pptx::export(&p).expect("export");
// Point the table at the probe style and replace the style part.
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
let name = f.name().to_string();
if name == "ppt/tableStyles.xml" {
d = style_xml().into_bytes();
} else if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
let s = String::from_utf8(d).expect("utf8");
let start = s.find("<a:tableStyleId>").map(|i| i + "<a:tableStyleId>".len());
let end = s.find("</a:tableStyleId>");
d = match (start, end) {
(Some(a), Some(b)) => format!("{}{}{}", &s[..a], STYLE_GUID, &s[b..]).into_bytes(),
_ => s.into_bytes(),
};
}
out.start_file(name, o).expect("start");
out.write_all(&d).expect("write");
}
out.finish().expect("finish").into_inner()
}
#[test]
fn table_styles_the_file_defines_are_read_used_and_kept() {
use deckcraft_model::table::{TableBorder, TablePart};
let p = deckcraft_pptx::import(&table_deck()).expect("import");
let def = p.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("the probe style is read");
assert_eq!(def.name, "Probe Style");
let bg = def.background.as_ref().expect("table background");
assert!(matches!(bg.fill_ref, Some((1, _))));
let whole = def.part(TablePart::WholeTbl).expect("whole table");
assert_eq!(whole.bold, Some(true));
assert!(whole.text_color.is_some());
assert!(matches!(&whole.borders[0], Some(TableBorder::Line { line }) if line.width == Some(2.0)));
assert!(matches!(&whole.borders[4], Some(TableBorder::Line { line }) if matches!(line.fill, Some(Fill::None))));
assert!(matches!(def.part(TablePart::Band1H).and_then(|b| b.fill.clone()), Some(Fill::Solid { .. })));
// The table keeps the style's GUID, so it's drawn from the definition.
let s = p.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("table");
let deckcraft_model::ShapeKind::Table(t) = &s.kind else { panic!("table") };
assert_eq!(t.style, STYLE_GUID);
// Saving writes the definition back, unchanged.
let again = deckcraft_pptx::export(&p).expect("export");
let q = deckcraft_pptx::import(&again).expect("reimport");
let def2 = q.table_styles.iter().find(|d| d.id == STYLE_GUID).expect("kept on save");
assert_eq!(def2.parts, def.parts);
}
+2
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
deckcraft-geom = { workspace = true }
deckcraft-color = { workspace = true }
deckcraft-metafile = { workspace = true }
deckcraft-model = { workspace = true }
deckcraft-fonts = { workspace = true }
deckcraft-text = { workspace = true }
@@ -19,6 +20,7 @@ vello_common = { workspace = true }
image = { workspace = true }
log = { workspace = true }
kurbo = { workspace = true }
skrifa = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
vello_cpu = { workspace = true, features = ["multithreading"] }
+212
View File
@@ -0,0 +1,212 @@
//! Colour glyphs (OpenType `COLR` v0 and v1, colours from `CPAL`), as colour emoji fonts draw
//! them: layers of glyph outlines filled with solid colours or gradients.
use deckcraft_color::Rgba;
use deckcraft_fonts::FontFace;
use kurbo::{Affine, BezPath, Point, Rect};
use skrifa::color::{Brush, ColorPainter, ColorStop, CompositeMode, Extend, Transform};
use skrifa::raw::types::BoundingBox;
use skrifa::{GlyphId, MetadataProvider};
use vello_cpu::{RenderContext, peniko};
/// Nesting depth of clips and layers past which a glyph opens no more (hostile fonts).
const MAX_DEPTH: usize = 64;
/// Paint `gid` of `face` as a colour glyph, its origin mapped by `at` (y down, points) at `scale`
/// points per font unit. `fg` is the text colour (palette index 0xFFFF) and `alpha` fades it all.
/// False when the glyph has no colour version, so the caller draws the plain outline.
pub fn paint(ctx: &mut RenderContext, face: &FontFace, gid: u32, at: Affine, scale: f64, fg: Rgba, alpha: f64) -> bool {
let Some(font) = face.skrifa() else { return false };
let glyphs = font.color_glyphs();
let Some(glyph) = glyphs.get(GlyphId::new(gid)) else { return false };
let palette: Vec<Rgba> = font
.color_palettes()
.get(0)
.map(|p| p.colors().iter().map(|c| Rgba { r: c.red, g: c.green, b: c.blue, a: c.alpha }).collect())
.unwrap_or_default();
// Font units are y up; the slide is y down.
let base = at * Affine::scale(scale) * Affine::scale_non_uniform(1.0, -1.0);
let mut p = Painter { ctx, face, base, stack: vec![Affine::IDENTITY], palette, fg, opened: Vec::new() };
p.ctx.push_layer(None, None, Some(alpha.clamp(0.0, 1.0) as f32), None, None);
let ok = glyph.paint(face.location(), &mut p).is_ok();
// A malformed glyph may leave layers open: close them all.
while let Some(open) = p.opened.pop() {
if open {
p.ctx.pop_layer();
}
}
p.ctx.pop_layer();
ok
}
struct Painter<'a> {
ctx: &'a mut RenderContext,
face: &'a FontFace,
base: Affine,
/// Transforms pushed by the glyph (font units), innermost last.
stack: Vec<Affine>,
palette: Vec<Rgba>,
fg: Rgba,
/// Clips and composite groups pushed by the glyph, innermost last: true when a layer was
/// opened on the context for it (false past `MAX_DEPTH`, so its pop is skipped too).
opened: Vec<bool>,
}
fn affine(t: &Transform) -> Affine {
Affine::new([t.xx as f64, t.yx as f64, t.xy as f64, t.yy as f64, t.dx as f64, t.dy as f64])
}
fn pt(p: skrifa::raw::types::Point<f32>) -> Point {
Point::new(p.x as f64, p.y as f64)
}
fn extend(e: Extend) -> peniko::Extend {
match e {
Extend::Repeat => peniko::Extend::Repeat,
Extend::Reflect => peniko::Extend::Reflect,
_ => peniko::Extend::Pad,
}
}
fn blend(m: CompositeMode) -> peniko::BlendMode {
use peniko::{Compose, Mix};
let (mix, compose) = match m {
CompositeMode::Clear => (Mix::Normal, Compose::Clear),
CompositeMode::Src => (Mix::Normal, Compose::Copy),
CompositeMode::Dest => (Mix::Normal, Compose::Dest),
CompositeMode::DestOver => (Mix::Normal, Compose::DestOver),
CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn),
CompositeMode::DestIn => (Mix::Normal, Compose::DestIn),
CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut),
CompositeMode::DestOut => (Mix::Normal, Compose::DestOut),
CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop),
CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop),
CompositeMode::Xor => (Mix::Normal, Compose::Xor),
CompositeMode::Plus => (Mix::Normal, Compose::Plus),
CompositeMode::Screen => (Mix::Screen, Compose::SrcOver),
CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver),
CompositeMode::Darken => (Mix::Darken, Compose::SrcOver),
CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver),
CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver),
CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver),
CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver),
CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver),
CompositeMode::Difference => (Mix::Difference, Compose::SrcOver),
CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver),
CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver),
CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver),
CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver),
CompositeMode::HslColor => (Mix::Color, Compose::SrcOver),
CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver),
_ => (Mix::Normal, Compose::SrcOver),
};
peniko::BlendMode::new(mix, compose)
}
impl Painter<'_> {
fn current(&self) -> Affine {
self.base * self.stack.last().copied().unwrap_or(Affine::IDENTITY)
}
fn color(&self, index: u16, alpha: f32) -> peniko::Color {
let c = if index == 0xFFFF { self.fg } else { self.palette.get(index as usize).copied().unwrap_or(self.fg) };
let a = (c.a as f64 * (alpha as f64).clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;
peniko::Color::from_rgba8(c.r, c.g, c.b, a)
}
fn stops(&self, stops: &[ColorStop]) -> Vec<peniko::ColorStop> {
stops.iter().map(|s| peniko::ColorStop { offset: s.offset.clamp(0.0, 1.0), color: self.color(s.palette_index, s.alpha).into() }).collect()
}
/// The glyph outline in font units, y up.
fn outline(&self, gid: GlyphId) -> BezPath {
let p = deckcraft_fonts::FontDb::global().outline(self.face, gid.to_u32());
Affine::scale_non_uniform(1.0, -1.0) * (*p).clone()
}
fn push_clip(&mut self, path: &BezPath) {
let open = self.opened.len() < MAX_DEPTH;
if open {
self.ctx.set_transform(self.current());
self.ctx.push_clip_layer(path);
}
self.opened.push(open);
}
fn pop(&mut self) {
if self.opened.pop() == Some(true) {
self.ctx.pop_layer();
}
}
/// Fill the open clip area (a large box in the current transform) with `paint`.
fn cover(&mut self, paint: impl Into<vello_cpu::PaintType>) {
self.ctx.set_transform(self.current());
self.ctx.set_paint(paint);
self.ctx.fill_rect(&Rect::new(-1.0e5, -1.0e5, 1.0e5, 1.0e5));
}
}
impl ColorPainter for Painter<'_> {
fn push_transform(&mut self, t: Transform) {
let next = self.stack.last().copied().unwrap_or(Affine::IDENTITY) * affine(&t);
self.stack.push(next);
}
fn pop_transform(&mut self) {
if self.stack.len() > 1 {
self.stack.pop();
}
}
fn push_clip_glyph(&mut self, gid: GlyphId) {
let path = self.outline(gid);
self.push_clip(&path);
}
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64);
self.push_clip(&kurbo::Shape::to_path(&r, 0.1));
}
fn pop_clip(&mut self) {
self.pop();
}
fn fill(&mut self, brush: Brush<'_>) {
match brush {
Brush::Solid { palette_index, alpha } => {
let c = self.color(palette_index, alpha);
self.cover(c);
}
Brush::LinearGradient { p0, p1, color_stops, extend: e } => {
let g = peniko::Gradient::new_linear(pt(p0), pt(p1)).with_stops(self.stops(color_stops).as_slice()).with_extend(extend(e));
self.cover(g);
}
Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => {
let g = peniko::Gradient::new_two_point_radial(pt(c0), r0.max(0.0), pt(c1), r1.max(0.0))
.with_stops(self.stops(color_stops).as_slice())
.with_extend(extend(e));
self.cover(g);
}
Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => {
let g = peniko::Gradient::new_sweep(pt(c0), start_angle.to_radians(), end_angle.to_radians())
.with_stops(self.stops(color_stops).as_slice())
.with_extend(extend(e));
self.cover(g);
}
}
}
fn push_layer(&mut self, mode: CompositeMode) {
let open = self.opened.len() < MAX_DEPTH;
if open {
self.ctx.push_blend_layer(blend(mode));
}
self.opened.push(open);
}
fn pop_layer(&mut self) {
self.pop();
}
}
+242
View File
@@ -0,0 +1,242 @@
//! Compound outlines (`a:ln/@cmpd`): double, thick-thin, thin-thick and triple lines.
//!
//! PowerPoint splits the line's width into bands across the path, measured from its left side
//! (looking along the path) to its right, as these shares of the width (read from PowerPoint's
//! export of 24 pt lines of each kind):
//! - double: 1/3 line, 1/3 gap, 1/3 line;
//! - thin-thick: 1/5 line, 1/5 gap, 3/5 line; thick-thin the mirror;
//! - triple: 1/6 line, 1/6 gap, 1/3 line, 1/6 gap, 1/6 line.
//!
//! On a shape drawn clockwise (all presets) the left side is the outside.
use deckcraft_model::style::Compound;
use kurbo::{BezPath, CubicBez, ParamCurve, PathEl, PathSeg, Point, Vec2};
/// The bands of `c` as (from, to) shares of the width, left side first.
pub fn bands(c: Compound) -> &'static [(f64, f64)] {
match c {
Compound::Single => &[(0.0, 1.0)],
Compound::Double => &[(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0)],
Compound::ThinThick => &[(0.0, 0.2), (0.4, 1.0)],
Compound::ThickThin => &[(0.0, 0.6), (0.8, 1.0)],
Compound::Triple => &[(0.0, 1.0 / 6.0), (2.0 / 6.0, 4.0 / 6.0), (5.0 / 6.0, 1.0)],
}
}
/// Unit normal pointing to the right of travel (y down), or None for a zero-length tangent.
fn right(t: Vec2) -> Option<Vec2> {
let l = t.hypot();
(l > 1e-12 && l.is_finite()).then(|| Vec2::new(-t.y, t.x) / l)
}
/// `path` moved `d` to the right of travel (negative: to the left). Line corners meet at their
/// miter point (bevelled when sharper than about 11°); curves are offset by kurbo; other joins are
/// bridged by a straight line.
pub fn offset(path: &BezPath, d: f64, tolerance: f64) -> BezPath {
let mut out = BezPath::new();
if d.abs() < 1e-9 {
return path.clone();
}
// Each subpath as segments, offset one by one.
let mut segs: Vec<PathSeg> = Vec::new();
let mut closed = false;
let flush = |segs: &mut Vec<PathSeg>, closed: bool, out: &mut BezPath| {
let pieces: Vec<(PathSeg, BezPath)> = segs.iter().filter_map(|s| offset_seg(*s, d, tolerance).map(|p| (*s, p))).collect();
let begin = out.elements().len();
for (i, (seg, piece)) in pieces.iter().enumerate() {
let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() else { continue };
match i.checked_sub(1).and_then(|j| pieces.get(j)) {
None => out.move_to(p),
Some((prev, _)) => join(out, *prev, *seg, p, d),
}
for e in piece.elements().iter().skip(1) {
out.push(*e);
}
}
if closed && let (Some((last, _)), Some((first, piece))) = (pieces.last(), pieces.first()) {
if let Some(PathEl::MoveTo(p)) = piece.elements().first().copied() {
// Lines meeting at the start: both ends move to their miter point.
match miter(*last, *first, d) {
Some(m) if is_line(*last) && is_line(*first) => {
set_last(out, m);
if let Some(PathEl::MoveTo(_)) = out.elements().get(begin) {
out.elements_mut()[begin] = PathEl::MoveTo(m);
}
}
_ => out.line_to(p),
}
}
out.close_path();
}
segs.clear();
};
let mut start = Point::ZERO;
let mut cur = Point::ZERO;
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => {
flush(&mut segs, closed, &mut out);
closed = false;
start = p;
cur = p;
}
PathEl::LineTo(p) => {
segs.push(PathSeg::Line(kurbo::Line::new(cur, p)));
cur = p;
}
PathEl::QuadTo(a, b) => {
segs.push(PathSeg::Quad(kurbo::QuadBez::new(cur, a, b)));
cur = b;
}
PathEl::CurveTo(a, b, c) => {
segs.push(PathSeg::Cubic(CubicBez::new(cur, a, b, c)));
cur = c;
}
PathEl::ClosePath => {
if (cur - start).hypot() > 1e-9 {
segs.push(PathSeg::Line(kurbo::Line::new(cur, start)));
}
closed = true;
flush(&mut segs, closed, &mut out);
closed = false;
cur = start;
}
}
}
flush(&mut segs, closed, &mut out);
out
}
fn offset_seg(s: PathSeg, d: f64, tolerance: f64) -> Option<BezPath> {
// Far-off or non-finite geometry (hostile files) is left out rather than offset.
let sane = |p: Point| p.is_finite() && p.x.abs() < 1e7 && p.y.abs() < 1e7;
let ok = match s {
PathSeg::Line(l) => sane(l.p0) && sane(l.p1),
PathSeg::Quad(q) => sane(q.p0) && sane(q.p1) && sane(q.p2),
PathSeg::Cubic(c) => sane(c.p0) && sane(c.p1) && sane(c.p2) && sane(c.p3),
};
if !ok || !d.is_finite() {
return None;
}
match s {
PathSeg::Line(l) => {
let n = right(l.p1 - l.p0)?;
let mut p = BezPath::new();
p.move_to(l.p0 + n * d);
p.line_to(l.p1 + n * d);
Some(p)
}
PathSeg::Quad(q) => offset_seg(PathSeg::Cubic(q.raise()), d, tolerance),
PathSeg::Cubic(c) => {
// Nearly straight cubics (collinear control points) are offset as lines.
let chord = c.p3 - c.p0;
let flat = |p: Point| chord.cross(p - c.p0).abs() <= 1e-9 * chord.hypot2().max(1e-18);
if flat(c.p1) && flat(c.p2) {
return offset_seg(PathSeg::Line(kurbo::Line::new(c.p0, c.p3)), d, tolerance);
}
let mut p = BezPath::new();
kurbo::offset::offset_cubic(c, d, tolerance, &mut p);
Some(p)
}
}
}
/// The segment's tangent at its start and at its end (the first non-zero control vector).
fn tangents(s: PathSeg) -> (Vec2, Vec2) {
let first = |v: &[Vec2]| v.iter().copied().find(|v| v.hypot2() > 1e-18).unwrap_or(Vec2::ZERO);
match s {
PathSeg::Line(l) => (l.p1 - l.p0, l.p1 - l.p0),
PathSeg::Quad(q) => (first(&[q.p1 - q.p0, q.p2 - q.p0]), first(&[q.p2 - q.p1, q.p2 - q.p0])),
PathSeg::Cubic(c) => (first(&[c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0]), first(&[c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0])),
}
}
fn is_line(s: PathSeg) -> bool {
matches!(s, PathSeg::Line(_))
}
/// Move the path's last point to `p`.
fn set_last(out: &mut BezPath, p: Point) {
if let Some(e) = out.elements_mut().last_mut() {
match e {
PathEl::MoveTo(q) | PathEl::LineTo(q) => *q = p,
PathEl::QuadTo(_, q) => *q = p,
PathEl::CurveTo(_, _, q) => *q = p,
PathEl::ClosePath => {}
}
}
}
/// Where the offsets of `prev` and `next` meet at their shared corner (their miter point), or
/// None for a corner sharper than about 11 degrees or a degenerate tangent.
fn miter(prev: PathSeg, next: PathSeg, d: f64) -> Option<Point> {
let (_, t0) = tangents(prev);
let (t1, _) = tangents(next);
let (n0, n1) = (right(t0)?, right(t1)?);
let denom = 1.0 + n0.dot(n1);
(denom > 0.02).then(|| prev.end() + (n0 + n1) * (d / denom)).filter(|m| m.is_finite())
}
/// Join the offset of `prev` (ending at `out`'s current point) to the offset of `next`, which
/// starts at `p`: two lines meet at their miter point (both ends moved there, so an inner corner
/// doesn't loop); anything else is bridged straight.
fn join(out: &mut BezPath, prev: PathSeg, next: PathSeg, p: Point, d: f64) {
match miter(prev, next, d) {
Some(m) if is_line(prev) && is_line(next) => set_last(out, m),
Some(m) => {
out.line_to(m);
out.line_to(p);
}
None => out.line_to(p),
}
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
#[test]
fn bands_split_the_width_like_powerpoint() {
for c in [Compound::Single, Compound::Double, Compound::ThinThick, Compound::ThickThin, Compound::Triple] {
let b = bands(c);
assert_eq!(b.first().map(|x| x.0), Some(0.0));
assert_eq!(b.last().map(|x| x.1), Some(1.0));
assert!(b.windows(2).all(|w| w[0].1 < w[1].0), "{c:?}: bands in order with gaps");
}
// Thin-thick: the thin line on the left, thick-thin its mirror.
let (tt, th) = (bands(Compound::ThinThick), bands(Compound::ThickThin));
assert!((tt[0].1 - tt[0].0) < (tt[1].1 - tt[1].0));
assert!((th[0].1 - th[0].0) > (th[1].1 - th[1].0));
}
#[test]
fn offsetting_a_rectangle_grows_or_shrinks_it() {
// Clockwise in y-down coordinates: the left of travel is the outside.
let r = kurbo::Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
let out = offset(&r, -5.0, 0.01).bounding_box();
assert!(
(out.x0 + 5.0).abs() < 1e-6 && (out.y0 + 5.0).abs() < 1e-6 && (out.x1 - 105.0).abs() < 1e-6 && (out.y1 - 55.0).abs() < 1e-6,
"{out:?}"
);
let inn = offset(&r, 5.0, 0.01).bounding_box();
assert!((inn.x0 - 5.0).abs() < 1e-6 && (inn.y1 - 45.0).abs() < 1e-6, "{inn:?}");
// Corners meet at their miter points: the area is the offset rectangle's.
assert!((offset(&r, -5.0, 0.01).area().abs() - 110.0 * 60.0).abs() < 1e-6);
}
#[test]
fn offsetting_a_circle_changes_its_radius() {
let c = kurbo::Circle::new((50.0, 50.0), 20.0).to_path(0.01);
let b = offset(&c, 4.0, 0.01).bounding_box();
let w = b.width() / 2.0;
assert!((w - 16.0).abs() < 0.2 || (w - 24.0).abs() < 0.2, "radius {w}");
// A hostile path never panics and yields finite output.
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((0.0, 0.0));
p.curve_to((f64::MAX, 0.0), (0.0, f64::MIN_POSITIVE), (1.0, 1.0));
p.close_path();
let _ = offset(&p, 3.0, 0.01);
}
}
+12 -3
View File
@@ -99,11 +99,20 @@ fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_co
Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE)))
}
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA.
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF; WMF and EMF drawn at
/// [`crate::metafile::DEFAULT_SIDE`]) to straight RGBA.
pub fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
decode_at(bytes, crate::metafile::DEFAULT_SIDE)
}
/// [`decode`], with a metafile drawn with its longer side `side` px.
fn decode_at(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
if bytes.len() > 512 << 20 {
return None;
}
if crate::metafile::is_metafile(bytes) {
return crate::metafile::rasterize(bytes, side);
}
let img = image::load_from_memory(bytes).ok()?;
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 || (w as u64) * (h as u64) > 400_000_000 {
@@ -131,13 +140,13 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
}) {
return Some(pm);
}
let Some(mut img) = decode(bytes) else {
let target = 1u32 << bucket;
let Some(mut img) = decode_at(bytes, target) else {
with(|c| {
c.bad.insert(addr, bytes.clone());
});
return None;
};
let target = 1u32 << bucket;
let (w, h) = img.dimensions();
if w.max(h) > target.max(16) && w.max(h) > 4096.min(target * 2) {
let k = target as f64 / w.max(h) as f64;
+181 -14
View File
@@ -8,13 +8,21 @@
#![forbid(unsafe_code)]
mod chart;
mod colr;
mod compound;
mod images;
mod metafile;
mod morph_path;
mod morph_text;
mod paint;
mod pattern;
mod pathgrad;
mod placed;
mod ramp;
mod table;
mod warp;
use std::collections::HashMap;
use std::sync::Arc;
@@ -247,7 +255,7 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
return;
}
let (e, _) = resolve::effects(&rctx, s);
if e.is_some_and(|e| !e.is_empty()) {
if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
any = true;
}
});
@@ -260,6 +268,20 @@ pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
any
}
/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
/// text styles it inherits)? Such text is blurred, which needs filters.
fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
use deckcraft_model::text::{ListStyle, RunProps};
let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0);
let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
let Some(t) = &s.text else { return false };
t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
|| styled(&t.list_style)
|| styled(&rctx.master.title_style)
|| styled(&rctx.master.body_style)
|| styled(&rctx.master.other_style)
}
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
/// blank image (logged), never a crash.
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
@@ -745,18 +767,23 @@ impl Renderer {
}
}
if let Some(r) = soft {
// Feather: fade the shape's alpha towards the outline.
// Feather: the shape (fill and line) fades to nothing at its outline. Measured on
// PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of
// r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself.
ctx.set_transform(m);
ctx.push_layer(
None,
Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)),
None,
None,
Some(blur(r * 0.5 * scale_of(m))),
Some(blur(r / 3.0 * scale_of(m))),
);
ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));
let inset = Rect::new(0.0, 0.0, w, h).inset(-r * 0.6);
ctx.fill_rect(&inset);
ctx.fill_path(&outline);
ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut));
ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round));
ctx.stroke_path(&outline);
ctx.pop_layer();
ctx.pop_layer();
ctx.pop_layer();
}
@@ -768,11 +795,14 @@ impl Renderer {
ctx.push_clip_layer(&outline);
let c = rctx.color(&sh.color, fx_ph);
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m))));
// As PowerPoint draws it (measured on its export): the shadow falls inside along the
// edges it points to (45 degrees: the bottom and right), blurred by a third of the
// blur radius.
ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m))));
ctx.set_paint(color(c, 1.0));
let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1);
let mut hole = outline.clone();
hole.apply_affine(Affine::translate(d));
hole.apply_affine(Affine::translate(-d));
ring.extend(hole.iter());
ctx.set_fill_rule(peniko::Fill::EvenOdd);
ctx.fill_path(&ring);
@@ -788,7 +818,10 @@ impl Renderer {
let tr = geo.text_rect;
if !body.is_empty() {
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
draw_layout_with(ctx, &l, tr, m, &st.paras);
match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
None => draw_layout_with(ctx, &l, tr, m, &st.paras),
}
} else if f.opts.edit && s.ph.is_some() {
let kind = s.ph_type().unwrap_or(PhType::Body);
let prompt_body = prompt_body(rctx, s, kind);
@@ -843,7 +876,8 @@ impl Renderer {
// Shadow offset is in slide space (doesn't rotate with the shape unless asked).
let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local };
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k)));
// Blurred by a third of the blur radius, as PowerPoint's export shows.
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
ctx.set_transform(sm);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_path(&silhouette);
@@ -852,10 +886,12 @@ impl Renderer {
if let Some(g) = &e.glow {
let c = rctx.color(&g.color, ph);
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k)));
// Measured on PowerPoint's export: a glow of radius r is half strength about half of r
// out from the outline and gone by r.
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k)));
ctx.set_transform(m);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
ctx.stroke_path(&silhouette);
ctx.fill_path(&silhouette);
ctx.pop_layer();
@@ -925,6 +961,19 @@ fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option<Rgba>, pa
}
_ => ctx.set_paint(color(Rgba::BLACK, alpha)),
}
// Compound lines: each band stroked along the path moved to its place across the width.
let compound = line.compound.unwrap_or_default();
if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 {
let tolerance = 0.1 / scale_of(m).max(1e-6);
for &(a, b) in compound::bands(compound) {
let d = (-0.5 + (a + b) / 2.0) * lw;
let mut band = sk.clone();
band.width = (b - a) * lw;
ctx.set_stroke(band);
ctx.stroke_path(&compound::offset(path, d, tolerance));
}
return;
}
ctx.set_stroke(sk);
ctx.stroke_path(path);
}
@@ -1102,6 +1151,13 @@ pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine {
t
}
/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws.
pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option<deckcraft_geom::warp::Warp> {
let bp = resolve::body(rctx, s);
let name = bp.warp.as_deref()?;
deckcraft_geom::warp::warp(name, &bp.warp_adj, tr)
}
/// Draw an already laid-out text block.
pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) {
draw_layout_with(ctx, l, tr, m, &[]);
@@ -1123,6 +1179,15 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);
}
// Text shadows, then glows, under all the text.
for pass in [TextEffect::Shadow, TextEffect::Glow] {
for run in &l.runs {
let ps = para(run.para);
if ps.visible && ps.opacity > 0.001 {
text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity);
}
}
}
for run in &l.runs {
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
continue;
@@ -1132,8 +1197,24 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
continue;
}
let m = m * Affine::translate(ps.offset);
// A gradient fill starts over on every line and spans that line's text (from its top to
// its bottom, from its first to its last character), as in PowerPoint.
let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
for (gid, x, y) in &run.glyphs {
draw_glyph(ctx, run, *gid, m * Affine::translate((*x, *y)), ps.opacity);
let at = m * Affine::translate((*x, *y));
let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
draw_glyph(ctx, run, *gid, at, ps.opacity);
continue;
};
let li = line_at(l, run.para, *y);
if !grads.iter().any(|(i, _)| *i == li) {
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr);
grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity)));
}
let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y))));
draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill);
}
}
for d in l.decos.iter().filter(|d| !d.behind) {
@@ -1141,20 +1222,106 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
}
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
l.lines
.iter()
.enumerate()
.filter(|(_, li)| li.para == para)
.min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs()))
.map(|(i, _)| i)
.unwrap_or(0)
}
/// The box a line's text covers: its top and bottom, its first and last caret.
fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x)));
let r = Rect::new(x0, li.top, x1, li.bottom);
(r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
}
#[derive(Clone, Copy, PartialEq)]
enum TextEffect {
Shadow,
Glow,
}
/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`.
fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) {
let Some(fx) = run.effects else { return };
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let px = scale_of(m);
let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) {
(TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0),
// Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r
// past the glyph's edge and fades out by about two thirds of r.
(TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68),
_ => return,
};
let alpha = (c.a as f64 / 255.0) * opacity;
if alpha <= 0.0 {
return;
}
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma)));
ctx.set_paint(color(c.with_alpha(255), 1.0));
for (gid, x, y) in &run.glyphs {
let path = glyph(&run.face, *gid);
if path.elements().is_empty() {
continue;
}
// The offset is in slide space: a shadow falls the same way whatever the text's rotation.
let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k);
ctx.set_transform(at);
ctx.fill_path(&path);
if grow > 0.0 {
ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
ctx.pop_layer();
}
/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) {
draw_glyph_with(ctx, run, gid, at, opacity, None);
}
/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from
/// the glyph's origin to that space) instead of the run's colour.
fn draw_glyph_with(
ctx: &mut RenderContext,
run: &deckcraft_text::GlyphRun,
gid: u32,
at: Affine,
opacity: f64,
fill: Option<(&peniko::Gradient, Affine)>,
) {
let alpha = run.alpha * opacity;
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
return;
}
let k = run.size / run.face.upem.max(1.0);
// Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still
// strokes the plain glyph below.
if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) {
return;
}
let path = glyph(&run.face, gid);
if path.elements().is_empty() {
return;
}
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
ctx.set_transform(at * skew * Affine::scale(k));
if alpha > 0.0 && run.color.a > 0 {
if let Some((g, to_text)) = fill
&& alpha > 0.0
{
ctx.set_paint(g.clone());
ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text);
ctx.fill_path(&path);
ctx.reset_paint_transform();
} else if alpha > 0.0 && run.color.a > 0 {
ctx.set_paint(color(run.color, alpha));
ctx.fill_path(&path);
if run.fake_bold {
+105
View File
@@ -0,0 +1,105 @@
//! Windows Metafile and Enhanced Metafile pictures (`image/x-wmf`, `image/x-emf`): parsed by
//! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go
//! through the same crop, tile and recolour path as any other picture.
use std::sync::Arc;
use deckcraft_metafile::PlacedItem;
use kurbo::{Affine, Rect, Shape as _};
use vello_cpu::{Pixmap, RenderContext, Resources, peniko};
/// Longest side of a metafile drawn without a target size (export, thumbnails of the picture).
pub const DEFAULT_SIDE: u32 = 1024;
/// Largest side drawn.
const MAX_SIDE: u32 = 8192;
/// Is `bytes` a WMF or EMF file?
pub fn is_metafile(bytes: &[u8]) -> bool {
deckcraft_metafile::is_metafile(bytes)
}
/// Draw the metafile `bytes` into straight RGBA with its longer side `side` px (transparent where
/// it draws nothing). `None` when it doesn't parse or has no size.
pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
let mut pic = deckcraft_metafile::parse(bytes).ok()?;
if !pic.has_size() {
return None;
}
let side = side.clamp(16, MAX_SIDE) as f64;
let (pw, ph) = (pic.width as f64, pic.height as f64);
let k = side / pw.max(ph);
let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
let pixmaps: Vec<Option<Arc<Pixmap>>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect();
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let r = Rect::new(0.0, 0.0, w as f64, h as f64);
ctx.push_clip_layer(&r.to_path(0.1));
for it in pic.place(r, [0.0; 4]) {
match it {
PlacedItem::Path { path, fill, stroke, even_odd } => {
if let Some(f) = fill {
ctx.set_paint(rgba(f));
ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
ctx.fill_path(&path);
}
// GDI pens: round caps and joins; a cosmetic (zero-width) pen is one pixel.
if let Some((s, sw)) = stroke {
ctx.set_paint(rgba(s));
ctx.set_stroke(kurbo::Stroke::new(sw.max(1.0)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
PlacedItem::Bitmap { index, rect } => {
let Some(Some(pm)) = pixmaps.get(index) else { continue };
let (bw, bh) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),
});
ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / bw, rect.height() / bh));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
}
}
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.pop_layer();
ctx.flush();
let mut px = vec![0u8; w as usize * h as usize * 4];
if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut px) {
ctx.render(pm, &mut Resources::new());
}
// Premultiplied to straight.
for p in px.as_chunks_mut::<4>().0 {
let a = p[3] as u32;
if a != 0 && a != 255 {
for c in p.iter_mut().take(3) {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
image::RgbaImage::from_raw(w as u32, h as u32, px)
}
fn rgba(c: [u8; 4]) -> peniko::Color {
peniko::Color::from_rgba8(c[0], c[1], c[2], c[3])
}
/// Straight RGBA rows to a premultiplied pixmap.
fn premul(w: u32, h: u32, px: &[u8]) -> Option<Arc<Pixmap>> {
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 || px.len() < w as usize * h as usize * 4 {
return None;
}
let data = px
.as_chunks::<4>()
.0
.iter()
.take(w as usize * h as usize)
.map(|p| {
let a = p[3] as u16;
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
})
.collect();
Some(Arc::new(Pixmap::from_parts(data, w as u16, h as u16)))
}
+43 -45
View File
@@ -35,8 +35,18 @@ pub fn fill_path(
if g.stops.is_empty() {
return;
}
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect();
ctx.set_paint(gradient(&g.shape, stops, bounds, alpha));
let mut stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0), shade(rctx.color(&s.color, ph), mode))).collect();
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
// Rectangle and outline paths: rasterised (see `pathgrad`).
if let GradientShape::Path { path: kind, focus } = &g.shape
&& matches!(kind.as_str(), "rect" | "shape")
{
crate::pathgrad::fill(ctx, kind, *focus, &crate::ramp::expand(&stops), bounds, path, m, alpha);
return;
}
// PowerPoint's ramp between stops (linear light, S-shaped), see `ramp`.
let stops: Vec<peniko::ColorStop> =
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
@@ -72,55 +82,11 @@ pub fn fill_path(
Fill::Pattern(p) => {
let fg = rctx.color(&p.fg, ph);
let bg = rctx.color(&p.bg, ph);
ctx.set_paint(color(bg, alpha));
ctx.fill_path(path);
ctx.push_clip_layer(path);
ctx.set_paint(color(fg, alpha));
pattern(ctx, &p.preset, bounds, m);
ctx.pop_layer();
crate::pattern::fill(ctx, &p.preset, fg, bg, alpha, path, m);
}
}
}
/// Draw a pattern's foreground marks over `bounds` (8 pt cells).
fn pattern(ctx: &mut RenderContext, preset: &str, b: Rect, m: Affine) {
let k = crate::scale_of(m);
let cell = 6.0 / if k.is_finite() { k.clamp(0.25, 4.0) } else { 1.0 };
let cell = cell.max(2.0);
let mut p = BezPath::new();
let (nx, ny) = (((b.width() / cell).ceil() as i64).clamp(0, 2000), ((b.height() / cell).ceil() as i64).clamp(0, 2000));
let lw = cell * 0.18;
let dense = preset.starts_with("dk") || preset.contains("50") || preset.contains("75") || preset.contains("90");
for j in 0..=ny {
for i in 0..=nx {
let x = b.x0 + i as f64 * cell;
let y = b.y0 + j as f64 * cell;
let r = match preset {
p if p.contains("Horz") || p == "horz" || p.contains("horzBrick") => Rect::new(x, y, x + cell, y + lw),
p if p.contains("Vert") || p == "vert" => Rect::new(x, y, x + lw, y + cell),
p if p.contains("cross") || p.contains("Grid") || p.contains("grid") => {
p_add(&mut p_rect(x, y, cell, lw), &mut BezPath::new());
Rect::new(x, y, x + cell, y + lw).union(Rect::new(x, y, x + lw, y + cell))
}
_ => {
let d = if dense { cell * 0.35 } else { cell * 0.2 };
Rect::new(x, y, x + d, y + d)
}
};
p.extend(r.to_path(0.1).iter());
if preset.contains("cross") || preset.contains("Grid") || preset.contains("grid") {
p.extend(Rect::new(x, y, x + lw, y + cell).to_path(0.1).iter());
}
}
}
ctx.fill_path(&p);
}
fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
Rect::new(x, y, x + w, y + h).to_path(0.1)
}
fn p_add(_a: &mut BezPath, _b: &mut BezPath) {}
/// `pf` with its duotone colours resolved to plain RGB against the slide's colours and the
/// placeholder colour `ph` (theme background pictures recolour with `phClr`), so the decoded-image
/// cache can apply and key them without a colour scheme.
@@ -131,6 +97,38 @@ pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option<Rgba>) -> std::b
std::borrow::Cow::Owned(out)
}
/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded
/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`).
pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient {
for s in &mut stops {
s.0 = s.0.clamp(0.0, 1.0);
}
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
let stops: Vec<peniko::ColorStop> =
crate::ramp::expand(&stops).into_iter().map(|(o, c)| peniko::ColorStop { offset: o as f32, color: color(c, alpha).into() }).collect();
match shape {
GradientShape::Linear { angle, .. } => {
// Angle measured clockwise from left→right; span the bounds along that direction.
let a = angle.to_radians();
let (dx, dy) = (a.cos(), a.sin());
let c = bounds.center();
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
// Reach the farthest corner, so a gradient from a corner covers the whole box.
let r = [(bounds.x0, bounds.y0), (bounds.x1, bounds.y0), (bounds.x0, bounds.y1), (bounds.x1, bounds.y1)]
.iter()
.map(|(x, y)| ((x - fx).powi(2) + (y - fy).powi(2)).sqrt())
.fold(1e-6, f64::max);
peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice())
}
}
}
/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes; an animated
/// GIF shows its frame at `gif_time` seconds.
pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine, gif_time: Option<f64>) {
+220
View File
@@ -0,0 +1,220 @@
//! Path gradients (`a:path`) that follow a rectangle or the shape's own outline, which no vello
//! gradient draws: they're rasterised into a small image of the bounds and painted with it.
//!
//! Measured on PowerPoint's export (black to grey to white from the centre):
//! - `rect`: the colour position is the larger of the horizontal and vertical distances from the
//! focus, each as a share of the distance to that side's edge (X-shaped contours);
//! - `shape`: contours are the outline scaled about the focus (an ellipse gives ellipses, a
//! triangle triangles): the share of the way from the focus to the outline along the same ray.
//!
//! `circle` stays a radial vello gradient (see `paint::gradient`).
use std::sync::Arc;
use deckcraft_color::Rgba;
use kurbo::{Affine, BezPath, PathEl, Point, Rect};
use vello_cpu::{Pixmap, RenderContext, peniko};
/// Longest side of the rasterised gradient, in pixels (bilinear sampling smooths the rest).
const MAX_SIDE: f64 = 384.0;
/// Colour position (0 at the focus, 1 at the edge) of `p` for path `kind` in `bounds` with the
/// focus rect `focus` (fractions inset from left, top, right, bottom), `outline` being the
/// shape's flattened polygon(s) for `shape`.
pub fn position(kind: &str, bounds: Rect, focus: [f64; 4], outline: &[Vec<Point>], p: Point) -> f64 {
let f = Rect::new(
bounds.x0 + bounds.width() * focus[0].clamp(0.0, 1.0),
bounds.y0 + bounds.height() * focus[1].clamp(0.0, 1.0),
bounds.x1 - bounds.width() * focus[2].clamp(0.0, 1.0),
bounds.y1 - bounds.height() * focus[3].clamp(0.0, 1.0),
);
let f = if f.x0 <= f.x1 && f.y0 <= f.y1 { f } else { Rect::from_center_size(f.center(), (0.0, 0.0)) };
match kind {
"shape" if !outline.is_empty() => shape_position(outline, f.center(), p),
_ => {
// Distance outside the focus rect along each axis, over the room to that side's edge.
let axis = |v: f64, lo: f64, hi: f64, edge_lo: f64, edge_hi: f64| {
if v < lo {
(lo - v) / (lo - edge_lo).max(1e-9)
} else if v > hi {
(v - hi) / (edge_hi - hi).max(1e-9)
} else {
0.0
}
};
axis(p.x, f.x0, f.x1, bounds.x0, bounds.x1).max(axis(p.y, f.y0, f.y1, bounds.y0, bounds.y1)).clamp(0.0, 1.0)
}
}
}
/// The share of the way from `c` to the outline along the ray through `p` (1 when the ray meets
/// no edge).
fn shape_position(outline: &[Vec<Point>], c: Point, p: Point) -> f64 {
let d = p - c;
let len = d.hypot();
if len < 1e-9 {
return 0.0;
}
// Nearest crossing of the ray c + s·d (s > 0) with an edge.
let mut best = f64::INFINITY;
for poly in outline {
for w in poly.windows(2) {
let (a, b) = (w[0], w[1]);
let e = b - a;
let den = d.cross(e);
if den.abs() < 1e-12 {
continue;
}
let ac = a - c;
let s = ac.cross(e) / den;
let u = ac.cross(d) / den;
if s > 1e-9 && (-1e-9..=1.0 + 1e-9).contains(&u) {
best = best.min(s);
}
}
}
if best.is_finite() { (1.0 / best).clamp(0.0, 1.0) } else { 1.0 }
}
/// The outline flattened into closed polygons.
pub fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
let mut out: Vec<Vec<Point>> = Vec::new();
let mut cur: Vec<Point> = Vec::new();
kurbo::flatten(path.iter(), 0.25, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur = vec![p];
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
});
if cur.len() > 1 {
out.push(cur);
}
for poly in &mut out {
if let (Some(f), Some(l)) = (poly.first().copied(), poly.last().copied())
&& (f - l).hypot() > 1e-9
{
poly.push(f);
}
}
out.retain(|p| p.len() > 2 && p.len() < 100_000);
out
}
/// A 256-entry colour ramp for the sorted, already expanded `stops` (linear in sRGB between them).
pub fn ramp(stops: &[(f64, Rgba)]) -> Vec<Rgba> {
(0..256)
.map(|i| {
let t = i as f64 / 255.0;
let after = stops.iter().position(|s| s.0 >= t);
match after {
Some(0) | None => stops.get(after.unwrap_or(stops.len().saturating_sub(1))).map(|s| s.1).unwrap_or(Rgba::BLACK),
Some(j) => {
let (Some(a), Some(b)) = (stops.get(j - 1), stops.get(j)) else { return Rgba::BLACK };
let k = if b.0 - a.0 > 1e-12 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
a.1.lerp(b.1, k)
}
}
})
.collect()
}
/// Fill `path` with a `rect` or `shape` path gradient of `stops` (sorted and expanded, see
/// `ramp::expand`) over `bounds`, faded by `alpha`; `m` maps the path to the output.
#[allow(clippy::too_many_arguments)]
pub fn fill(ctx: &mut RenderContext, kind: &str, focus: [f64; 4], stops: &[(f64, Rgba)], bounds: Rect, path: &BezPath, m: Affine, alpha: f64) {
if bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() || stops.is_empty() {
return;
}
let k = crate::scale_of(m);
let k = if k.is_finite() && k > 0.0 { k } else { 1.0 };
let longest = bounds.width().max(bounds.height()) * k;
let s = (MAX_SIDE / longest).min(1.0) * k;
let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 4096), ((bounds.height() * s).ceil() as u32).clamp(1, 4096));
let outline = if kind == "shape" { polygons(path) } else { Vec::new() };
let lut = ramp(stops);
let a = alpha.clamp(0.0, 1.0);
let mut data = Vec::with_capacity((pw * ph) as usize);
for j in 0..ph {
for i in 0..pw {
let p = Point::new(bounds.x0 + (i as f64 + 0.5) / pw as f64 * bounds.width(), bounds.y0 + (j as f64 + 0.5) / ph as f64 * bounds.height());
let t = position(kind, bounds, focus, &outline, p);
let c = lut.get((t * 255.0).round().clamp(0.0, 255.0) as usize).copied().unwrap_or(Rgba::BLACK);
let ca = (c.a as f64 * a).round().clamp(0.0, 255.0);
let mul = |v: u8| (v as f64 * ca / 255.0).round().clamp(0.0, 255.0) as u8;
data.push(vello_cpu::color::PremulRgba8 { r: mul(c.r), g: mul(c.g), b: mul(c.b), a: ca as u8 });
}
}
let pm = Arc::new(Pixmap::from_parts(data, pw as u16, ph as u16));
ctx.set_transform(m);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() },
});
ctx.set_paint_transform(
Affine::translate(bounds.origin().to_vec2()) * Affine::scale_non_uniform(bounds.width() / pw as f64, bounds.height() / ph as f64),
);
ctx.fill_path(path);
ctx.reset_paint_transform();
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
const CENTRE: [f64; 4] = [0.5, 0.5, 0.5, 0.5];
#[test]
fn rect_paths_take_the_larger_axis_share() {
// PowerPoint, 280x200 box from the centre: 20 px across is 1/7, 20 px down is 1/5.
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
let at = |x: f64, y: f64| position("rect", b, CENTRE, &[], Point::new(x, y));
assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 1e-9);
assert!((at(140.0, 120.0) - 20.0 / 100.0).abs() < 1e-9);
assert!((at(168.0, 120.0) - 0.2).abs() < 1e-9, "the diagonal takes the larger share");
assert_eq!(at(0.0, 0.0), 1.0);
// From a corner (fillToRect 0,0,100%,100%): the far corner is the end.
let corner = [0.0, 0.0, 1.0, 1.0];
assert!((position("rect", b, corner, &[], Point::new(140.0, 50.0)) - 0.5).abs() < 1e-9);
}
#[test]
fn shape_paths_follow_the_outline() {
let b = Rect::new(0.0, 0.0, 280.0, 200.0);
let ellipse = polygons(&kurbo::Ellipse::from_rect(b).to_path(0.01));
let at = |x: f64, y: f64| position("shape", b, CENTRE, &ellipse, Point::new(x, y));
assert!((at(160.0, 100.0) - 20.0 / 140.0).abs() < 0.01);
assert!((at(140.0, 120.0) - 0.2).abs() < 0.01);
// Half way to the outline along any ray is 0.5.
let (dx, dy) = (140.0 * 0.6, 100.0 * 0.8);
assert!((at(140.0 + dx * 0.5, 100.0 + dy * 0.5) - 0.5).abs() < 0.01);
// A triangle: half way from its centre to its apex is 0.5.
let mut tri = BezPath::new();
tri.move_to((140.0, 0.0));
tri.line_to((280.0, 200.0));
tri.line_to((0.0, 200.0));
tri.close_path();
let polys = polygons(&tri);
assert!((position("shape", b, CENTRE, &polys, Point::new(140.0, 50.0)) - 0.5).abs() < 1e-6);
}
#[test]
fn the_ramp_runs_through_the_stops() {
let stops = [(0.0, Rgba::rgb(0, 0, 0)), (0.5, Rgba::rgb(128, 128, 128)), (1.0, Rgba::rgb(255, 255, 255))];
let r = ramp(&stops);
assert_eq!(r.len(), 256);
assert_eq!(r[0], Rgba::rgb(0, 0, 0));
assert_eq!(r[255], Rgba::rgb(255, 255, 255));
assert!((r[64].r as i32 - 64).abs() <= 2);
assert!(ramp(&[(0.3, Rgba::rgb(9, 9, 9))]).iter().all(|c| *c == Rgba::rgb(9, 9, 9)));
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Preset pattern fills (`a:pattFill`): an 8×8 two-colour tile, each cell one 96-dpi pixel
//! (0.75 pt) of the slide, as PowerPoint draws them.
//!
//! The tiles were read from PowerPoint's own slide export of every preset (black on white, at
//! exactly two output pixels per pattern pixel), so the marks, their density and their phase
//! match it. Bit 7 of each row is the leftmost cell; a set bit is the foreground colour.
use std::sync::Arc;
use deckcraft_color::Rgba;
use kurbo::{Affine, BezPath};
use vello_cpu::{Pixmap, RenderContext, peniko};
/// Side of one pattern cell, in points.
pub const CELL: f64 = 0.75;
const TILES: &[(&str, [u8; 8])] = &[
("pct5", [0x80, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00]),
("pct10", [0x80, 0x00, 0x08, 0x00, 0x80, 0x00, 0x08, 0x00]),
("pct20", [0x88, 0x00, 0x22, 0x00, 0x88, 0x00, 0x22, 0x00]),
("pct25", [0x88, 0x22, 0x88, 0x22, 0x88, 0x22, 0x88, 0x22]),
("pct30", [0xAA, 0x44, 0xAA, 0x11, 0xAA, 0x44, 0xAA, 0x11]),
("pct40", [0xAA, 0x55, 0xAA, 0x51, 0xAA, 0x55, 0xAA, 0x15]),
("pct50", [0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]),
("pct60", [0xEE, 0x55, 0xBB, 0x55, 0xEE, 0x55, 0xBB, 0x55]),
("pct70", [0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD, 0x77, 0xDD]),
("pct75", [0x77, 0xFF, 0xDD, 0xFF, 0x77, 0xFF, 0xDD, 0xFF]),
("pct80", [0xEF, 0xFF, 0xFE, 0xFF, 0xEF, 0xFF, 0xFE, 0xFF]),
("pct90", [0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xFF, 0x7F]),
("horz", [0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
("vert", [0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("ltHorz", [0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00]),
("ltVert", [0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88]),
("dkHorz", [0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00]),
("dkVert", [0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC]),
("narHorz", [0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00]),
("narVert", [0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55]),
("dashHorz", [0xF0, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00]),
("dashVert", [0x80, 0x80, 0x80, 0x80, 0x08, 0x08, 0x08, 0x08]),
("cross", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("dnDiag", [0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01]),
("upDiag", [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]),
("ltDnDiag", [0x88, 0x44, 0x22, 0x11, 0x88, 0x44, 0x22, 0x11]),
("ltUpDiag", [0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88]),
("dkDnDiag", [0xCC, 0x66, 0x33, 0x99, 0xCC, 0x66, 0x33, 0x99]),
("dkUpDiag", [0x33, 0x66, 0xCC, 0x99, 0x33, 0x66, 0xCC, 0x99]),
("wdDnDiag", [0xC1, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x83]),
("wdUpDiag", [0x83, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xC1]),
("dashDnDiag", [0x00, 0x00, 0x88, 0x44, 0x22, 0x11, 0x00, 0x00]),
("dashUpDiag", [0x00, 0x00, 0x11, 0x22, 0x44, 0x88, 0x00, 0x00]),
("diagCross", [0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81]),
("smCheck", [0x99, 0x66, 0x66, 0x99, 0x99, 0x66, 0x66, 0x99]),
("lgCheck", [0xF0, 0xF0, 0xF0, 0xF0, 0x0F, 0x0F, 0x0F, 0x0F]),
("smGrid", [0xFF, 0x88, 0x88, 0x88, 0xFF, 0x88, 0x88, 0x88]),
("lgGrid", [0xFF, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]),
("dotGrid", [0xAA, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00]),
("smConfetti", [0x80, 0x08, 0x40, 0x02, 0x10, 0x01, 0x20, 0x04]),
("lgConfetti", [0xB1, 0x30, 0x03, 0x1B, 0xD8, 0xC0, 0x0C, 0x8D]),
("horzBrick", [0xFF, 0x80, 0x80, 0x80, 0xFF, 0x08, 0x08, 0x08]),
("diagBrick", [0x01, 0x02, 0x04, 0x08, 0x18, 0x24, 0x42, 0x81]),
("solidDmnd", [0x10, 0x38, 0x7C, 0xFE, 0x7C, 0x38, 0x10, 0x00]),
("openDmnd", [0x82, 0x44, 0x28, 0x10, 0x28, 0x44, 0x82, 0x01]),
("dotDmnd", [0x80, 0x00, 0x22, 0x00, 0x08, 0x00, 0x22, 0x00]),
("plaid", [0xAA, 0x55, 0xAA, 0x55, 0xF0, 0xF0, 0xF0, 0xF0]),
("sphere", [0x77, 0x89, 0x8F, 0x8F, 0x77, 0x98, 0xF8, 0xF8]),
("weave", [0x88, 0x54, 0x22, 0x45, 0x88, 0x14, 0x22, 0x51]),
("divot", [0x00, 0x10, 0x08, 0x10, 0x00, 0x80, 0x01, 0x80]),
("shingle", [0x03, 0x84, 0x48, 0x30, 0x0C, 0x02, 0x01, 0x01]),
("wave", [0x00, 0x18, 0x25, 0xC0, 0x00, 0x18, 0x25, 0xC0]),
("trellis", [0xFF, 0x66, 0xFF, 0x99, 0xFF, 0x66, 0xFF, 0x99]),
("zigZag", [0x81, 0x42, 0x24, 0x18, 0x81, 0x42, 0x24, 0x18]),
];
/// The 8×8 tile of preset `name` (`pct50` for an unknown name).
pub fn tile(name: &str) -> [u8; 8] {
TILES
.iter()
.find(|(n, _)| *n == name)
.or_else(|| TILES.iter().find(|(n, _)| *n == "pct50"))
.map(|(_, t)| *t)
.unwrap_or([0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55])
}
/// Is cell (`x`, `y`) of the tile the foreground?
pub fn is_fg(tile: &[u8; 8], x: usize, y: usize) -> bool {
tile.get(y % 8).is_some_and(|row| row & (0x80 >> (x % 8)) != 0)
}
fn premul(c: Rgba, alpha: f64) -> vello_cpu::color::PremulRgba8 {
let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0);
let m = |v: u8| (v as f64 * a / 255.0).round().clamp(0.0, 255.0) as u8;
vello_cpu::color::PremulRgba8 { r: m(c.r), g: m(c.g), b: m(c.b), a: a as u8 }
}
/// Fill `path` (in the space `m` maps to the output) with preset `name` in `fg` on `bg`, faded by
/// `alpha`.
pub fn fill(ctx: &mut RenderContext, name: &str, fg: Rgba, bg: Rgba, alpha: f64, path: &BezPath, m: Affine) {
let det = m.determinant();
if !det.is_finite() || det.abs() <= 1e-12 {
return;
}
let t = tile(name);
let (f, b) = (premul(fg, alpha), premul(bg, alpha));
let data: Vec<vello_cpu::color::PremulRgba8> = (0..64).map(|i| if is_fg(&t, i % 8, i / 8) { f } else { b }).collect();
let pm = Arc::new(Pixmap::from_parts(data, 8, 8));
ctx.set_transform(m);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler {
x_extend: peniko::Extend::Repeat,
y_extend: peniko::Extend::Repeat,
quality: peniko::ImageQuality::Low,
alpha: 1.0,
},
});
// Cells are laid out on the output from its origin (the slide's top-left corner), so
// neighbouring shapes' patterns line up, as in PowerPoint.
let k = crate::scale_of(m);
let k = if k.is_finite() && k > 0.0 { k } else { 1.0 };
ctx.set_paint_transform(m.inverse() * Affine::scale(CELL * k));
ctx.fill_path(path);
ctx.reset_paint_transform();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_drawingml_preset_has_a_tile() {
// ECMA-376 ST_PresetPatternVal.
let all = [
"pct5",
"pct10",
"pct20",
"pct25",
"pct30",
"pct40",
"pct50",
"pct60",
"pct70",
"pct75",
"pct80",
"pct90",
"horz",
"vert",
"ltHorz",
"ltVert",
"dkHorz",
"dkVert",
"narHorz",
"narVert",
"dashHorz",
"dashVert",
"cross",
"dnDiag",
"upDiag",
"ltDnDiag",
"ltUpDiag",
"dkDnDiag",
"dkUpDiag",
"wdDnDiag",
"wdUpDiag",
"dashDnDiag",
"dashUpDiag",
"diagCross",
"smCheck",
"lgCheck",
"smGrid",
"lgGrid",
"dotGrid",
"smConfetti",
"lgConfetti",
"horzBrick",
"diagBrick",
"solidDmnd",
"openDmnd",
"dotDmnd",
"plaid",
"sphere",
"weave",
"divot",
"shingle",
"wave",
"trellis",
"zigZag",
];
assert_eq!(all.len(), 54);
for n in all {
assert!(TILES.iter().any(|(t, _)| *t == n), "{n}");
}
assert_eq!(TILES.len(), 54);
}
#[test]
fn percent_presets_get_denser() {
// The pctN tiles set more cells as N grows (PowerPoint's own: pct5 sets 2 of 64, pct50
// half, pct90 all but 2).
let share = |n: &str| {
let t = tile(n);
(0..64).filter(|i| is_fg(&t, i % 8, i / 8)).count()
};
let names = ["pct5", "pct10", "pct20", "pct25", "pct30", "pct40", "pct50", "pct60", "pct70", "pct75", "pct80", "pct90"];
assert!(names.windows(2).all(|w| share(w[0]) <= share(w[1])), "{:?}", names.map(share));
assert_eq!((share("pct5"), share("pct50"), share("pct90")), (2, 32, 62));
assert_eq!(tile("nonsense"), tile("pct50"));
}
}
+149 -11
View File
@@ -3,7 +3,7 @@
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::resolve::Ctx;
use deckcraft_model::style::{ColorRef, Fill};
use deckcraft_model::table::{Cell, Table};
use deckcraft_model::table::{Cell, Table, TableBorder, TablePart, TablePartStyle, TableStyleDef};
use deckcraft_model::text::{Anchor, TextBody, TextDir};
use deckcraft_model::{Presentation, Shape};
use deckcraft_text::Fields;
@@ -18,6 +18,12 @@ pub struct CellLook {
pub text: Option<Rgba>,
pub bold: bool,
pub border: Option<(Rgba, f64)>,
/// Left, right, top and bottom borders from a file-defined style (a fully transparent colour
/// means no border); `None` falls back to `border`.
pub sides: [Option<(Rgba, f64)>; 4],
/// A file-defined style's fill as a paint (gradients, see-through colours), with its
/// placeholder colour; drawn instead of `fill`.
pub paint: Option<(Fill, Option<Rgba>)>,
}
fn accent(ctx: &Ctx, style: &str) -> Rgba {
@@ -33,7 +39,109 @@ fn accent(ctx: &Ctx, style: &str) -> Rgba {
ctx.color(&ColorRef::scheme(slot), None)
}
/// The look of cell (`r`, `c`) from a table style the file defines: each part that applies to
/// the cell (whole table, bands, first and last rows and columns, corners, in that order) sets the
/// fill, text and the borders on its region's edges (its inside lines within it).
fn look_def(ctx: &Ctx, def: &TableStyleDef, t: &Table, r: usize, c: usize) -> CellLook {
let (rows, cols) = (t.rows.len(), t.cols.len());
let (r1, c1) = t.cell(r, c).map(|cell| (r + cell.row_span.max(1) as usize - 1, c + cell.grid_span.max(1) as usize - 1)).unwrap_or((r, c));
let (r1, c1) = (r1.min(rows.saturating_sub(1)), c1.min(cols.saturating_sub(1)));
let last_r = rows.saturating_sub(1);
let last_c = cols.saturating_sub(1);
let header = t.first_row && r == 0;
let total = t.last_row && r1 == last_r && rows > 1;
let first_col = t.first_col && c == 0;
let last_col = t.last_col && c1 == last_c && cols > 1;
let body_r = r.saturating_sub(usize::from(t.first_row));
let body_c = c.saturating_sub(usize::from(t.first_col));
let mut look = CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None };
for part in TablePart::ALL {
// (applies, region rows, region cols)
let (on, region) = match part {
TablePart::WholeTbl => (true, (0, last_r, 0, last_c)),
TablePart::Band1H => (t.band_row && !header && !total && body_r % 2 == 0, (r, r1, 0, last_c)),
TablePart::Band2H => (t.band_row && !header && !total && body_r % 2 == 1, (r, r1, 0, last_c)),
TablePart::Band1V => (t.band_col && !first_col && !last_col && body_c % 2 == 0, (0, last_r, c, c1)),
TablePart::Band2V => (t.band_col && !first_col && !last_col && body_c % 2 == 1, (0, last_r, c, c1)),
TablePart::LastCol => (last_col, (0, last_r, c, last_c)),
TablePart::FirstCol => (first_col, (0, last_r, 0, c1)),
TablePart::LastRow => (total, (r, last_r, 0, last_c)),
TablePart::FirstRow => (header, (0, r1, 0, last_c)),
TablePart::SeCell => (total && last_col, (r, r1, c, c1)),
TablePart::SwCell => (total && first_col, (r, r1, c, c1)),
TablePart::NeCell => (header && last_col, (r, r1, c, c1)),
TablePart::NwCell => (header && first_col, (r, r1, c, c1)),
};
if !on {
continue;
}
let Some(ps) = def.part(part) else { continue };
if let Some(p) = style_fill(ctx, ps) {
look.fill = fill_color(ctx, &p.0, p.1);
look.paint = Some(p);
}
if let Some(tc) = &ps.text_color {
look.text = Some(ctx.color(tc, None));
}
if let Some(b) = ps.bold {
look.bold = b;
}
let (r0, rr1, c0, cc1) = region;
// Left, right, top, bottom of the cell: the part's edge or its inside line.
let pick = [if c == c0 { 0 } else { 5 }, if c1 == cc1 { 1 } else { 5 }, if r == r0 { 2 } else { 4 }, if r1 == rr1 { 3 } else { 4 }];
for (side, i) in pick.iter().enumerate() {
if let (Some(Some(b)), Some(slot)) = (ps.borders.get(*i), look.sides.get_mut(side)) {
*slot = Some(border_look(ctx, b));
}
}
}
look
}
/// A style part's fill, a theme fill reference resolved to the theme's fill with its colour as
/// the placeholder colour.
fn style_fill(ctx: &Ctx, ps: &TablePartStyle) -> Option<(Fill, Option<Rgba>)> {
if let Some((idx, col)) = &ps.fill_ref {
let ph = ctx.color(col, None);
return Some(match ctx.master.theme.format.fills.get((*idx as usize).saturating_sub(1)) {
Some(f) if *idx > 0 => (f.clone(), Some(ph)),
_ if *idx == 0 => (Fill::None, None),
_ => (Fill::solid(ColorRef::rgb(ph)), None),
});
}
ps.fill.clone().map(|f| (f, None))
}
/// A fill's colour for a table cell (gradients and pictures by their first colour).
fn fill_color(ctx: &Ctx, f: &Fill, ph: Option<Rgba>) -> Option<Rgba> {
match f {
Fill::None => Some(Rgba::TRANSPARENT),
Fill::Solid { color: c } => Some(ctx.color(c, ph)),
Fill::Gradient(g) => g.stops.first().map(|s| ctx.color(&s.color, ph)),
Fill::Pattern(p) => Some(ctx.color(&p.bg, ph)),
_ => None,
}
}
/// A style border's colour and width (a transparent colour for no line).
fn border_look(ctx: &Ctx, b: &TableBorder) -> (Rgba, f64) {
match b {
TableBorder::Line { line } => match &line.fill {
Some(Fill::Solid { color: c }) => (ctx.color(c, None), line.width.unwrap_or(0.75)),
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), line.width.unwrap_or(0.75)),
_ => (Rgba::TRANSPARENT, 0.0),
},
TableBorder::Ref { idx, color } => {
let w = ctx.master.theme.format.lines.get((*idx as usize).saturating_sub(1)).and_then(|l| l.width).unwrap_or(0.75);
if *idx == 0 { (Rgba::TRANSPARENT, 0.0) } else { (ctx.color(color, None), w) }
}
}
}
pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
if let Some(def) = ctx.pres.table_styles.iter().find(|d| d.id.eq_ignore_ascii_case(&t.style)) {
return look_def(ctx, def, t, r, c);
}
let a = accent(ctx, &t.style);
let kind = t.style.split('-').next().unwrap_or("medium2");
let header = t.first_row && r == 0;
@@ -47,19 +155,23 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
let white = Rgba::WHITE;
let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None);
match kind {
"none" => CellLook { fill: None, text: None, bold: false, border: None },
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)) },
"none" => CellLook { fill: None, text: None, bold: false, border: None, sides: [None; 4], paint: None },
"grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)), sides: [None; 4], paint: None },
"light1" => CellLook {
fill: if band || band_c { Some(tint(20000)) } else { None },
text: None,
bold: header || total || first_col || last_col,
border: if header { Some((a, 1.0)) } else { None },
sides: [None; 4],
paint: None,
},
"light2" => CellLook {
fill: if header { Some(a) } else { None },
text: if header { Some(white) } else { None },
bold: header || total || first_col,
border: Some((a, 1.0)),
sides: [None; 4],
paint: None,
},
"dark1" => CellLook {
fill: Some(if header {
@@ -72,12 +184,16 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
text: Some(white),
bold: header || total || first_col,
border: Some((white, 1.0)),
sides: [None; 4],
paint: None,
},
"medium4" => CellLook {
fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }),
text: None,
bold: header || total || first_col || last_col,
border: Some((a, 0.75)),
sides: [None; 4],
paint: None,
},
_ => CellLook {
// medium2: strong header, banded light tints, white grid.
@@ -91,6 +207,8 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
text: if header || total || first_col || last_col { Some(white) } else { None },
bold: header || total || first_col || last_col,
border: Some((white, 1.0)),
sides: [None; 4],
paint: None,
},
}
}
@@ -182,6 +300,21 @@ pub fn cell_rects(t: &Table, heights: &[f64]) -> Vec<(usize, usize, Rect)> {
pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn deckcraft_text::Fields) {
let _ = pres;
let rects = cell_rects(t, &row_heights(rctx, t, fields));
// Table backgrounds behind every cell: a file-defined style's, then the table's own fill.
let style_bg = rctx
.pres
.table_styles
.iter()
.find(|d| d.id.eq_ignore_ascii_case(&t.style))
.and_then(|d| d.background.as_ref())
.and_then(|b| style_fill(rctx, b));
let own_bg = t.background.clone().map(|f| (f, None));
if let Some(all) = rects.iter().map(|(_, _, r)| *r).reduce(|a, b| a.union(b)) {
for (bg, ph) in style_bg.into_iter().chain(own_bg) {
ctx.set_transform(m);
crate::paint::fill_path(ctx, rctx, &bg, ph, &all.to_path(0.1), all, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
}
}
for (r, c, rect) in &rects {
let Some(cell) = t.cell(*r, *c) else { continue };
let lk = look(rctx, t, *r, *c);
@@ -190,12 +323,17 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
Some(f) => {
crate::paint::fill_path(ctx, rctx, f, None, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
}
None => {
if let Some(fc) = lk.fill {
ctx.set_paint(color(fc, 1.0));
ctx.fill_rect(rect);
None => match &lk.paint {
Some((f, ph)) => {
crate::paint::fill_path(ctx, rctx, f, *ph, &rect.to_path(0.1), *rect, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres)
}
}
None => {
if let Some(fc) = lk.fill.filter(|f| f.a > 0) {
ctx.set_paint(color(fc, 1.0));
ctx.fill_rect(rect);
}
}
},
}
}
// Borders: explicit cell borders, else the style's grid.
@@ -216,9 +354,9 @@ pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table,
Some(Fill::None) => continue,
_ => continue,
},
None => match lk.border {
Some(b) => b,
None => continue,
None => match lk.sides.get(i).copied().flatten().or(lk.border) {
Some(b) if b.0.a > 0 && b.1 > 0.0 => b,
_ => continue,
},
};
let mut p = BezPath::new();
+517 -3
View File
@@ -119,9 +119,10 @@ fn effects_tables_charts_render() {
all.extend(charts);
let p = deck_with(all);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Shadow darkens below-right of the shape.
let px = img.pixel(258, 156);
assert!(px[0] < 250, "{px:?}");
// Shadow darkens below-right of the shape (grey, past the glow), blurred by a third of its
// 8 pt blur radius.
let px = img.pixel(255, 155);
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
}
#[test]
@@ -632,3 +633,516 @@ fn a_background_is_laid_over_white() {
let g = img.pixel(20, 20);
assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}");
}
#[test]
fn colour_glyphs_paint_their_layers() {
// A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue
// square in its middle, U+E001 a square in the text colour.
deckcraft_fonts::FontDb::global().add_font(include_bytes!("../tests/fixtures/colr-squares.ttf").to_vec());
let mut sh = Shape { id: ShapeId(720), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("\u{E000} \u{E001}");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(72.0);
props.font = Some("DeckCraft Colr Squares".into());
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 160, 0))));
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let count = |f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
let red = count(&|p| p[0] > 230 && p[1] < 30 && p[2] < 30);
let blue = count(&|p| p[0] < 30 && p[1] < 30 && p[2] > 230);
let green = count(&|p| p[0] < 30 && p[1] > 140 && p[1] < 180 && p[2] < 30);
// 72 pt squares: the blue one is 40 % of the side (16 % of the area).
assert!(red > 2500 && blue > 500, "red {red} blue {blue}");
let share = blue as f64 / (red + blue) as f64;
assert!((share - 0.16).abs() < 0.03, "blue share {share}");
// The text-colour glyph is as big as the other square.
assert!((green as f64 / (red + blue) as f64 - 1.0).abs() < 0.1, "green {green} vs {}", red + blue);
}
#[test]
fn a_text_gradient_starts_over_on_every_line() {
// PowerPoint fills each line of gradient text with the whole gradient, spanning that line
// (black at the top of every line to white at its bottom), not the text box.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
let mut sh = Shape { id: ShapeId(730), xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 500.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("HHHH\nHHHH");
for p in &mut t.paragraphs {
let props = &mut p.runs[0].props;
props.size = Some(80.0);
props.fill = Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(1.0, 255)],
shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true,
}));
}
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Ink rows (any pixel not white), grouped into the two lines.
let ink = |y: u32| (0..600).any(|x| img.pixel(x, y)[0] < 250);
let mut bands: Vec<(u32, u32)> = vec![];
for y in 0..500 {
if ink(y) {
match bands.last_mut() {
Some(b) if b.1 + 1 == y => b.1 = y,
_ => bands.push((y, y)),
}
}
}
assert_eq!(bands.len(), 2, "{bands:?}");
// The darkest ink in the first rows of each line.
let top = |b: (u32, u32)| (b.0..b.0 + 6).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
let (a, b) = (top(bands[0]), top(bands[1]));
assert!(a < 140 && (a as i32 - b as i32).abs() < 25, "line tops {a} and {b}: the second line starts dark again");
// ...and each line ends light.
let bottom = |b: (u32, u32)| (b.1 - 5..=b.1).flat_map(|y| (0..600).map(move |x| (x, y))).map(|(x, y)| img.pixel(x, y)[0]).min().unwrap();
for band in bands {
assert!(bottom(band) > 150, "line {band:?} ends at {}", bottom(band));
}
}
/// A text box of big black "IIII" with `set` applied to its run.
fn effect_text(set: impl Fn(&mut deckcraft_model::text::RunProps)) -> Presentation {
let mut sh = Shape { id: ShapeId(740), xfrm: Some(Xfrm::new(100.0, 100.0, 600.0, 300.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("IIII");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(100.0);
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
set(props);
sh.text = Some(t);
deck_with(vec![sh])
}
#[test]
fn text_glow_and_shadow_are_drawn() {
let count = |img: &Image, f: &dyn Fn(&[u8; 4]) -> bool| img.pixels.as_chunks::<4>().0.iter().filter(|px| f(px)).count();
let green = |p: &[u8; 4]| p[1] > 150 && p[0] < 120 && p[2] < 120;
let plain = effect_text(|_| {});
assert!(!needs_filters(&plain, &plain.slides[0]));
let img = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!(count(&img, &green), 0);
// Glow: a green halo hugging the letters, needing the blur filter.
let glow =
effect_text(|p| p.glow = Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 255, 0)), radius: 8.0 }));
assert!(needs_filters(&glow, &glow.slides[0]), "text glow blurs");
let img = render_slide(&glow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
let halo = count(&img, &green);
assert!(halo > 300, "green halo pixels {halo}");
// Shadow: grey ink below and right of the letters, where the plain text has none.
let shadow = effect_text(|p| {
p.shadow = Some(deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba { r: 0, g: 0, b: 0, a: 128 }),
blur: 2.0,
dist: 12.0,
dir: 45.0,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
})
});
let img = render_slide(&shadow, 0, &RenderOpts { scale: 1.0, threads: 4, ..Default::default() });
let base = render_slide(&plain, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let grey = |p: &[u8; 4]| p[0] > 60 && p[0] < 230 && p[0] == p[1] && p[1] == p[2];
assert!(count(&img, &grey) > count(&base, &grey) + 300, "a grey shadow appears");
}
#[test]
fn pattern_fills_draw_powerpoints_tiles() {
// dkVert at one output pixel per pattern cell (0.75 pt): columns 11001100… across, as
// PowerPoint's export draws it; pct50 is a checkerboard.
use deckcraft_model::style::{Fill, PatternFill};
let rgb = |v| ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v));
let shape = |id, x: f64, preset: &str| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(x, 0.0, 60.0, 60.0)),
fill: Some(Fill::Pattern(PatternFill { preset: preset.into(), fg: rgb(0), bg: rgb(255) })),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![shape(750, 0.0, "dkVert"), shape(751, 120.0, "pct50")]);
let img = render_slide(&p, 0, &RenderOpts { scale: 4.0 / 3.0, ..Default::default() });
let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 128;
let row: Vec<bool> = (8..16).map(|x| dark(x, 20)).collect();
assert_eq!(row, [true, true, false, false, true, true, false, false], "dkVert columns");
assert!((8..16).all(|y| dark(8, y) == dark(8, 20)), "dkVert is vertical");
// pct50 starts at slide x = 120 pt = 160 px.
for (x, y) in [(168, 8), (169, 9), (170, 12)] {
assert_ne!(dark(x, y), dark(x + 1, y), "pct50 alternates across at {x},{y}");
assert_eq!(dark(x, y), dark(x + 1, y + 1), "pct50 repeats diagonally at {x},{y}");
}
}
#[test]
fn compound_lines_draw_their_bands() {
// A 24 pt outline on a rectangle, as PowerPoint's export draws each kind (top edge, outside
// first): double 8/8/8, thin-thick 4.8/4.8/14.4, triple 4/4/8/4/4.
use deckcraft_model::style::{Compound, Fill, Line};
let runs = |c: Compound| {
let sh = Shape {
id: ShapeId(760),
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
line: Some(Line {
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))),
width: Some(24.0),
compound: Some(c),
..Default::default()
}),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 2.0, ..Default::default() });
// Down the middle of the top edge (y 100 pt), from 80 pt to 120 pt: lengths of dark and
// light runs in points.
let mut out: Vec<(bool, f64)> = vec![];
for y in 160..240 {
let dark = img.pixel(500, y)[0] < 128;
match out.last_mut() {
Some((d, n)) if *d == dark => *n += 0.5,
_ => out.push((dark, 0.5)),
}
}
out.into_iter().filter(|(d, _)| *d).map(|(_, n)| n).collect::<Vec<f64>>()
};
let close = |got: Vec<f64>, want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0);
assert!(close(runs(Compound::Single), &[24.0]));
let d = runs(Compound::Double);
assert!(close(d.clone(), &[8.0, 8.0]), "{d:?}");
let t = runs(Compound::ThinThick);
assert!(close(t.clone(), &[4.8, 14.4]), "{t:?}");
let k = runs(Compound::ThickThin);
assert!(close(k.clone(), &[14.4, 4.8]), "{k:?}");
let tri = runs(Compound::Triple);
assert!(close(tri.clone(), &[4.0, 8.0, 4.0]), "{tri:?}");
}
#[test]
fn rect_path_gradients_draw_x_shaped_contours() {
// path="rect" from the centre, black to grey to white (linear): PowerPoint's colour position is
// the larger axis share, so 20 pt right of centre in a 280x200 box is 1/7 and 20 pt down 1/5.
use deckcraft_model::style::{Fill, Gradient, GradientShape, GradientStop};
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
let sh = Shape {
id: ShapeId(770),
xfrm: Some(Xfrm::new(0.0, 0.0, 280.0, 200.0)),
fill: Some(Fill::Gradient(Gradient {
stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)],
shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] },
rotate_with_shape: true,
})),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let v = |x: u32, y: u32| img.pixel(x, y)[0] as f64;
assert!((v(160, 100) - 255.0 / 7.0).abs() < 6.0, "{}", v(160, 100));
assert!((v(140, 120) - 51.0).abs() < 6.0, "{}", v(140, 120));
// Along the diagonal to a corner the colour follows the vertical share.
assert!((v(168, 120) - 51.0).abs() < 6.0, "{}", v(168, 120));
}
#[test]
fn soft_edges_fade_to_the_outline() {
// An ellipse with a 12 pt soft edge: transparent at its outline, half opaque about 10 pt
// inside, opaque further in, as in PowerPoint's export (the fade follows the curve, not the
// bounding box).
let sh = Shape {
id: ShapeId(780),
xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)),
geom: Geom::Preset { name: "ellipse".into(), adj: vec![] },
fill: Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))),
line: Some(deckcraft_model::style::Line { fill: Some(deckcraft_model::style::Fill::None), ..Default::default() }),
effects: Some(deckcraft_model::Effects { soft_edge: Some(12.0), ..Default::default() }),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let alpha = |x: u32, y: u32| 1.0 - img.pixel(x, y)[0] as f64 / 255.0;
// Top of the ellipse is at y = 100, x = 250.
assert!(alpha(250, 101) < 0.08, "edge {}", alpha(250, 101));
let mid = alpha(250, 110);
assert!((0.3..0.7).contains(&mid), "0.85 r inside: {mid}");
assert!(alpha(250, 130) > 0.97, "inside {}", alpha(250, 130));
// Near the ends of the bounding box's top edge, outside the ellipse, nothing is drawn.
assert!(alpha(110, 105) < 0.01);
}
#[test]
fn a_file_defined_table_style_draws_its_parts() {
// Red table background (tblBg), see-through white on banded rows, a 4 pt blue left border and
// no inside lines: drawn from the definition, not DeckCraft's own look for the style.
use deckcraft_model::style::{Fill, Line};
use deckcraft_model::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef};
let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b));
let mut t = Table::new(4, 2, 400.0, 50.0);
t.style = "{ABCDEF01-2345-4678-9ABC-DEF012345678}".into();
t.first_row = false;
t.band_row = true;
let line = |c: ColorRef, w| TableBorder::Line { line: Line { fill: Some(Fill::solid(c)), width: Some(w), ..Default::default() } };
let mut whole = TablePartStyle { fill: Some(Fill::None), ..Default::default() };
whole.borders[0] = Some(line(rgb(0, 0, 255), 4.0));
whole.borders[4] = Some(TableBorder::Line { line: Line { fill: Some(Fill::None), ..Default::default() } });
let see_through = ColorRef::rgb(deckcraft_color::Rgba { r: 255, g: 255, b: 255, a: 51 });
let band = TablePartStyle { fill: Some(Fill::solid(see_through)), ..Default::default() };
let def = TableStyleDef {
id: t.style.clone(),
name: "Probe".into(),
parts: vec![(TablePart::WholeTbl, whole), (TablePart::Band1H, band)],
background: Some(TablePartStyle { fill: Some(Fill::solid(rgb(255, 0, 0))), ..Default::default() }),
raw: String::new(),
};
let tbl = Shape { id: ShapeId(790), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Table(t), ..Default::default() };
let mut p = deck_with(vec![tbl]);
p.table_styles.push(def);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let px = |x, y| img.pixel(x, y);
// Row 1 (band1H): red with 20 % white over it; row 2 (band2H, undefined): plain red.
let banded = px(300, 125);
assert!(banded[0] > 250 && (40..65).contains(&banded[1]), "{banded:?}");
assert_eq!(px(300, 175)[..3], [255, 0, 0]);
// No line between the rows; a blue left border.
let edge = px(300, 150);
assert!(edge[0] > 240, "no inside line: {edge:?}");
let left = px(100, 175);
assert!(left[2] > 200 && left[0] < 60, "{left:?}");
}
#[test]
fn a_table_fill_shows_behind_cells_without_their_own() {
// `a:tblPr` solidFill (tdf#127237): cells with no fill show the table's blue.
use deckcraft_model::style::Fill;
let mut t = Table::new(2, 2, 200.0, 50.0);
t.style = "none".into();
t.background = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 112, 192))));
for r in 0..2 {
for c in 0..2 {
t.cell_mut(r, c).unwrap().fill = Some(Fill::None);
}
}
let tbl = Shape { id: ShapeId(795), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), kind: ShapeKind::Table(t), ..Default::default() };
let p = deck_with(vec![tbl]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!(img.pixel(150, 160)[..3], [0, 112, 192]);
assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]);
}
/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt.
fn effect_box(effects: deckcraft_model::Effects) -> Image {
use deckcraft_model::style::{Fill, Line};
let sh = Shape {
id: ShapeId(800),
xfrm: Some(Xfrm::new(20.0, 20.0, 280.0, 200.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
line: Some(Line { fill: Some(Fill::None), ..Default::default() }),
effects: Some(effects),
..Default::default()
};
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
fn shadow(blur: f64, dist: f64, dir: f64) -> deckcraft_model::style::Shadow {
deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)),
blur,
dist,
dir,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
}
}
#[test]
fn shadows_and_glows_spread_like_powerpoints() {
let red = |img: &Image, x: u32, y: u32| img.pixel(x, y)[0] as i32;
// Outer shadow, 9 pt blur, no offset: PowerPoint's export reads 144, 204, 238 at 1, 3 and 5
// px outside the left edge.
let img = effect_box(deckcraft_model::Effects { outer_shadow: Some(shadow(9.0, 0.0, 0.0)), ..Default::default() });
for (d, want) in [(1, 144), (3, 204), (5, 238)] {
assert!((red(&img, 20 - d, 120) - want).abs() <= 6, "outer {d}: {}", red(&img, 20 - d, 120));
}
// Inner shadow pointing down-right (45 degrees): it falls inside along the bottom and right
// edges, not the top and left.
let mut sh = shadow(0.0, 10.0, 45.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
assert!(red(&img, 296, 120) < 20, "right edge {}", red(&img, 296, 120));
assert!(red(&img, 160, 216) < 20, "bottom edge {}", red(&img, 160, 216));
assert!(red(&img, 24, 120) > 250 && red(&img, 160, 24) > 250, "top and left stay clear");
// Inner shadow with a 9 pt blur and no offset: the same profile as the outer one, inside.
let mut sh = shadow(9.0, 0.0, 0.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
for (d, want) in [(0, 144), (2, 204), (4, 238)] {
assert!((red(&img, 20 + d, 120) - want).abs() <= 6, "inner {d}: {}", red(&img, 20 + d, 120));
}
// Glow of radius 10 pt: nearly solid 1 px out, about half strength 5 px out, gone by 10.
let glow = deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), radius: 10.0 };
let img = effect_box(deckcraft_model::Effects { glow: Some(glow), ..Default::default() });
assert!(red(&img, 19, 120) < 20);
assert!((60..170).contains(&red(&img, 15, 120)), "{}", red(&img, 15, 120));
assert!(red(&img, 10, 120) > 245);
}
/// A 400 × 200 text box at (100, 100) holding `text` at 40 pt, warped by `warp`.
fn wordart(warp: Option<&str>, text: &str) -> Presentation {
let mut t = TextBody::from_text(text);
t.paragraphs[0].runs[0].props.size = Some(40.0);
t.paragraphs[0].props.align = Some(deckcraft_model::text::Align::Center);
t.body.warp = warp.map(String::from);
deck_with(vec![Shape { id: ShapeId(700), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), text: Some(t), ..Default::default() }])
}
/// Ink box (x0, y0, x1, y1) of dark pixels, and the top ink row of each column.
fn ink(img: &Image) -> ((u32, u32, u32, u32), Vec<Option<u32>>) {
let mut b = (u32::MAX, u32::MAX, 0, 0);
let mut tops = vec![None; img.width as usize];
for y in 0..img.height {
for x in 0..img.width {
if img.pixel(x, y)[0] < 128 {
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
if tops[x as usize].is_none() {
tops[x as usize] = Some(y);
}
}
}
}
(b, tops)
}
#[test]
fn wordart_arch_up_lifts_the_middle_of_the_line() {
let o = RenderOpts { scale: 1.0, ..Default::default() };
let flat = render_slide(&wordart(None, "ARCHED WORDART"), 0, &o);
let arched = render_slide(&wordart(Some("textArchUp"), "ARCHED WORDART"), 0, &o);
assert!(diff_px(&flat, &arched) > 1000);
let ((fx0, _, fx1, _), ftops) = ink(&flat);
let ((ax0, ay0, ax1, _), atops) = ink(&arched);
let top_near = |tops: &[Option<u32>], x: u32| (x.saturating_sub(15)..x + 15).filter_map(|i| tops.get(i as usize).copied().flatten()).min();
// Flat text: the ends and the middle stand on one line.
let (fl, fm) = (top_near(&ftops, fx0 + 20).unwrap(), top_near(&ftops, (fx0 + fx1) / 2).unwrap());
assert!(fl.abs_diff(fm) < 4, "flat {fl} {fm}");
// Arched: the line follows the top of the ellipse inscribed in the box, so the middle is
// at the box top and the ends sit clearly lower.
let (al, am, ar) = (top_near(&atops, ax0 + 20).unwrap(), top_near(&atops, (ax0 + ax1) / 2).unwrap(), top_near(&atops, ax1 - 20).unwrap());
assert!(al > am + 10 && ar > am + 10, "arched {al} {am} {ar}");
// At the box top (y = 100), give or take the font: Linux's fallback face reaches 79.
assert!((70..=110).contains(&ay0), "arch top {ay0}");
}
#[test]
fn wordart_plain_stretches_the_ink_over_the_text_rect() {
let img = render_slide(&wordart(Some("textPlain"), "PLAIN"), 0, &RenderOpts { scale: 1.0, ..Default::default() });
let ((x0, y0, x1, y1), _) = ink(&img);
for (got, want) in [(x0, 100), (y0, 100), (x1, 499), (y1, 299)] {
assert!(got.abs_diff(want) <= 2, "ink box {:?}", (x0, y0, x1, y1));
}
}
#[test]
fn unknown_or_absent_warps_draw_flat_text() {
let o = RenderOpts { scale: 0.5, ..Default::default() };
// Warm the font database first (other tests load fonts concurrently).
let _ = render_slide(&wordart(None, "FLAT"), 0, &o);
let flat = render_slide(&wordart(None, "FLAT"), 0, &o);
for w in ["textNoShape", "bogus", ""] {
assert_eq!(diff_px(&flat, &render_slide(&wordart(Some(w), "FLAT"), 0, &o)), 0, "{w}");
}
}
#[test]
fn every_warp_preset_renders_text_inside_the_slide() {
let o = RenderOpts { scale: 0.5, ..Default::default() };
for name in
["textArchUp", "textArchDown", "textCircle", "textButton", "textWave1", "textRingInside", "textCirclePour", "textDeflateInflateDeflate"]
{
let mut p = wordart(Some(name), "One line\nTwo line\nThree");
if let Some(b) = std::sync::Arc::make_mut(&mut p.slides[0]).shapes[0].text.as_mut() {
b.body.warp_adj = vec![f64::NAN, -1e12];
}
let img = render_slide(&p, 0, &o);
let ((x0, y0, x1, y1), _) = ink(&img);
assert!(x0 <= x1 && y0 <= y1, "{name}: no ink");
}
}
/// A placeable WMF, 2 × 1 inch (2880 × 1440 twips): a solid red brush and a rectangle over the
/// left half, nothing on the right.
fn half_red_wmf() -> Vec<u8> {
fn rec(func: u16, params: &[i16]) -> Vec<u8> {
let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec();
v.extend(func.to_le_bytes());
for p in params {
v.extend(p.to_le_bytes());
}
v
}
// CREATEBRUSHINDIRECT (solid, COLORREF 0x000000FF = red), SELECTOBJECT 0, RECTANGLE (bottom,
// right, top, left).
let body = [rec(0x02FC, &[0, 0xFF, 0, 0]), rec(0x012D, &[0]), rec(0x041B, &[1440, 1440, 0, 0]), rec(0, &[])].concat();
let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
f.extend(0u16.to_le_bytes());
for v in [0i16, 0, 2880, 1440] {
f.extend(v.to_le_bytes());
}
f.extend(1440u16.to_le_bytes());
f.extend(0u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
let total = (f.len() + 18 + body.len()) / 2;
for v in [1u16, 9, 0x300] {
f.extend(v.to_le_bytes());
}
f.extend((total as u32).to_le_bytes());
f.extend(16u16.to_le_bytes());
f.extend(0u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
f.extend(body);
f
}
#[test]
fn metafile_pictures_draw_their_vectors() {
let mut p = Presentation::default();
let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf());
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
for scale in [0.5, 1.0, 3.0] {
let img = render_slide(&p, 0, &RenderOpts { scale, ..Default::default() });
let at = |x: f64, y: f64| img.pixel((x * scale) as u32, (y * scale) as u32);
let left = at(200.0, 200.0);
assert!(left[0] > 200 && left[1] < 60 && left[2] < 60, "left half red at {scale}: {left:?}");
let right = at(400.0, 200.0);
assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "right half empty at {scale}: {right:?}");
}
}
#[test]
fn broken_metafiles_draw_nothing_and_never_panic() {
let wmf = half_red_wmf();
let mut cases: Vec<Vec<u8>> = (0..wmf.len()).step_by(3).map(|n| wmf[..n].to_vec()).collect();
cases.push(vec![1, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF]);
let mut huge = wmf.clone();
huge[10] = 0xFF;
huge[11] = 0x7F;
cases.push(huge);
for bytes in cases {
let mut p = Presentation::default();
let media = p.add_media("image1.emf", "image/x-emf", bytes);
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let _ = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() });
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Drawing laid-out text through a WordArt warp ([`deckcraft_geom::warp`]).
//!
//! Glyph outlines are placed in layout space, flattened, cut into short segments and every point
//! is mapped through the warp, so straight glyph edges bend with the guides. Underlines and
//! highlights are warped the same way.
use deckcraft_geom::warp::{Warp, WarpLine};
use deckcraft_text::{GlyphRun, TextLayout};
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _};
use vello_cpu::{RenderContext, peniko};
use crate::{ParaState, color, glyph, scale_of, text_transform};
/// Most points one warped glyph may have (a hostile size or zoom can't explode the work).
const MAX_POINTS: usize = 40_000;
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to.
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
let mut best: Option<(usize, f64)> = None;
for (i, li) in l.lines.iter().enumerate().filter(|(_, li)| li.para == para) {
let inside = y >= li.top - 0.01 && y <= li.bottom + 0.01;
let d = if inside { 0.0 } else { (li.baseline - y).abs() };
if best.is_none_or(|(_, bd)| d < bd) {
best = Some((i, d));
}
}
best.map(|(i, _)| i)
}
/// Where a line meets a follow guide, above its baseline: 3/8 of the way up the font's
/// ascent + descent box (measured on Arial, Times New Roman and Courier New in PowerPoint).
fn ref_offset(run: &GlyphRun) -> f64 {
let f = &run.face;
let k = run.size / f.upem.max(1.0);
((0.375 * (f.ascent + f.descent) - f.descent) * k).clamp(-run.size, run.size)
}
/// `path` flattened with tolerance `tol`, cut into pieces of at most `seg` and mapped by `f`.
fn warp_path(path: &BezPath, tol: f64, seg: f64, f: &dyn Fn(Point) -> Point) -> BezPath {
let mut out = BezPath::new();
let mut last: Option<Point> = None;
let mut start: Option<Point> = None;
let mut budget = MAX_POINTS;
let segment = |out: &mut BezPath, a: Point, b: Point, budget: &mut usize| {
let n = ((b - a).hypot() / seg).ceil();
let n = if n.is_finite() { (n as usize).clamp(1, 512) } else { 1 };
for i in 1..=n {
if *budget == 0 {
return;
}
*budget -= 1;
out.line_to(f(a.lerp(b, i as f64 / n as f64)));
}
};
kurbo::flatten(path.iter(), tol, |el| match el {
PathEl::MoveTo(p) => {
if let (Some(a), Some(s)) = (last, start)
&& (a - s).hypot() > 1e-9
{
segment(&mut out, a, s, &mut budget);
}
out.move_to(f(p));
last = Some(p);
start = Some(p);
}
PathEl::LineTo(p) => {
if let Some(a) = last {
segment(&mut out, a, p, &mut budget);
}
last = Some(p);
}
PathEl::ClosePath => {
if let (Some(a), Some(s)) = (last, start) {
segment(&mut out, a, s, &mut budget);
}
out.close_path();
last = start;
}
_ => {}
});
out
}
struct Item<'a> {
run: &'a GlyphRun,
path: BezPath,
line: usize,
state: ParaState,
}
/// Draw `l` (laid out in text rect `tr`, shape transform `m`) through `warp`.
pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)], warp: &Warp) {
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
let m = text_transform(m, tr, l.rotation);
let n = l.lines.len();
let mut ink: Vec<Option<Rect>> = vec![None; n];
let mut off: Vec<Option<f64>> = vec![None; n];
let mut items: Vec<Item> = Vec::new();
for run in &l.runs {
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
for (gid, x, y) in &run.glyphs {
let Some(li) = line_of(l, run.para, *y) else { continue };
if let Some(o) = off.get_mut(li) {
*o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run))));
}
let outline = glyph(&run.face, *gid);
if outline.elements().is_empty() {
continue;
}
let mut path = (*outline).clone();
path.apply_affine(Affine::translate((*x, *y)) * skew * Affine::scale(k));
let b = path.bounding_box();
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) {
continue;
}
if let Some(slot) = ink.get_mut(li) {
*slot = Some(slot.map_or(b, |r| r.union(b)));
}
items.push(Item { run, path, line: li, state: para(run.para) });
}
}
let lines: Vec<WarpLine> = l
.lines
.iter()
.enumerate()
.map(|(i, li)| {
let o = off.get(i).copied().flatten().unwrap_or(0.18 * (li.bottom - li.top));
WarpLine { ink: ink.get(i).copied().flatten(), extent: li.extent, ref_y: li.baseline - o }
})
.collect();
let mapper = warp.mapper(&lines, l.inner);
let px = scale_of(m).max(1e-6) * mapper.zoom();
let tol = (0.1 / px).clamp(1e-4, 2.0);
let seg = (2.0 / px).clamp(1e-3, 50.0);
let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
let ps = para(d.para);
if !ps.visible || ps.opacity <= 0.001 {
return;
}
let y = (d.rect.y0 + d.rect.y1) / 2.0;
let Some(li) = line_of(l, d.para, y) else { return };
let path = warp_path(&d.rect.to_path(0.1), tol, seg, &|p| mapper.map(li, p));
ctx.set_transform(m * Affine::translate(ps.offset));
ctx.set_paint(color(d.color, ps.opacity));
ctx.fill_path(&path);
};
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);
}
for it in &items {
let (run, ps) = (it.run, it.state);
if !ps.visible || ps.opacity <= 0.001 {
continue;
}
let alpha = run.alpha * ps.opacity;
if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
continue;
}
let path = warp_path(&it.path, tol, seg, &|p| mapper.map(it.line, p));
ctx.set_transform(m * Affine::translate(ps.offset));
if alpha > 0.0 && run.color.a > 0 {
ctx.set_paint(color(run.color, alpha));
ctx.fill_path(&path);
if run.fake_bold {
ctx.set_stroke(kurbo::Stroke::new(run.size * 0.025).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
if let Some((oc, ow)) = run.outline {
ctx.set_paint(color(oc, ps.opacity));
ctx.set_stroke(kurbo::Stroke::new(ow.max(0.0)).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
for d in l.decos.iter().filter(|d| !d.behind) {
deco(ctx, d);
}
ctx.set_fill_rule(peniko::Fill::NonZero);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warp_path_subdivides_and_maps_every_point() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((10.0, 0.0));
p.line_to((10.0, 10.0));
p.close_path();
let out = warp_path(&p, 0.01, 1.0, &|q| Point::new(q.x, q.y + q.x * q.x * 0.01));
// 10 + 10 + ~15 pieces of at most 1 pt.
let n = out.elements().len();
assert!(n > 30, "{n}");
// The first edge is bent onto the parabola.
let mid = out.elements().iter().find_map(|e| if let PathEl::LineTo(q) = e { (q.x == 5.0).then_some(*q) } else { None });
assert_eq!(mid, Some(Point::new(5.0, 0.25)));
// A huge path with a tiny segment length stays bounded.
let mut big = BezPath::new();
big.move_to((0.0, 0.0));
for i in 0..1000 {
big.line_to((1e6 * (i % 2) as f64, i as f64));
}
let out = warp_path(&big, 0.01, 1e-3, &|q| q);
assert!(out.elements().len() <= MAX_POINTS + 2);
}
}
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
"""Build the COLR test font: DeckCraft Colr Squares (contributor-original, CC0-1.0).
Usage: python make_colr_font.py colr-squares.ttf (needs fontTools)
U+E000: a 1000x1000 red square (palette 0) with a 400x400 blue square (palette 1) in its middle.
U+E001: a 1000x1000 square in the text colour (palette index 0xFFFF).
Plain outlines of the base glyphs are a single square, as colour fonts carry for renderers
without colour support.
"""
import sys
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.colorLib.builder import buildCOLR, buildCPAL
def square(x0, y0, x1, y1):
pen = TTGlyphPen(None)
pen.moveTo((x0, y0))
pen.lineTo((x0, y1))
pen.lineTo((x1, y1))
pen.lineTo((x1, y0))
pen.closePath()
return pen.glyph()
def empty():
return TTGlyphPen(None).glyph()
order = [".notdef", "space", "redblue", "ink", "big", "small"]
fb = FontBuilder(1000, isTTF=True)
fb.setupGlyphOrder(order)
fb.setupCharacterMap({0x20: "space", 0xE000: "redblue", 0xE001: "ink"})
fb.setupGlyf({
".notdef": empty(),
"space": empty(),
"redblue": square(0, 0, 1000, 1000),
"ink": square(0, 0, 1000, 1000),
"big": square(0, 0, 1000, 1000),
"small": square(300, 300, 700, 700),
})
fb.setupHorizontalMetrics({g: (1000, 0) for g in order})
fb.setupHorizontalHeader(ascent=1000, descent=0)
fb.setupNameTable({"familyName": "DeckCraft Colr Squares", "styleName": "Regular"})
fb.setupOS2(sTypoAscender=1000, sTypoDescender=0, usWinAscent=1000, usWinDescent=0)
fb.setupPost()
fb.font["COLR"] = buildCOLR({"redblue": [("big", 0), ("small", 1)], "ink": [("big", 0xFFFF)]}, version=0)
fb.font["CPAL"] = buildCPAL([[(1.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0, 1.0)]])
fb.save(sys.argv[1])
print("saved", sys.argv[1])
+201 -11
View File
@@ -57,6 +57,25 @@ pub struct GlyphRun {
/// Alpha of the fill (0–1).
pub alpha: f64,
pub gradient: Option<deckcraft_model::style::Gradient>,
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
pub effects: Option<RunEffects>,
}
/// A text run's shadow and glow, colours resolved.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RunEffects {
pub shadow: Option<TextShadow>,
/// Glow colour (with its alpha) and radius in points.
pub glow: Option<(Rgba, f64)>,
}
/// An outer shadow: colour (with its alpha), blur radius and offset in points.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextShadow {
pub color: Rgba,
pub blur: f64,
pub dx: f64,
pub dy: f64,
}
impl std::fmt::Debug for GlyphRun {
@@ -174,6 +193,7 @@ struct Style {
outline: Option<(Rgba, f64)>,
link: bool,
gradient: Option<deckcraft_model::style::Gradient>,
effects: Option<RunEffects>,
/// "Regular", "Bold"… for the slot and fallback faces.
style_name: &'static str,
/// BCP 47 language (`a:rPr/@lang`), for shaping.
@@ -183,6 +203,8 @@ struct Style {
cs: Option<String>,
/// Heading font (`+mj-…`): the theme's major font set applies.
major: bool,
/// Pair kerning on: `a:rPr/@kern` is set and the size reaches it.
kern: bool,
}
/// One character of a paragraph after shaping.
@@ -239,13 +261,20 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
let family = resolve::font_family(ctx, r);
let bold = r.bold.unwrap_or(false);
let italic = r.italic.unwrap_or(false);
let face = FontDb::global().face(&family, style_name(bold, italic));
let face = FontDb::global().document_face(&family, style_name(bold, italic));
let fake_bold = bold && face.weight < 550.0;
let fake_italic = italic && !face.italic;
let size = (r.size.unwrap_or(18.0) * scale).clamp(0.5, 4000.0);
let (mut color, alpha, gradient) = match &r.fill {
Some(Fill::Solid { color }) => (ctx.color(color, None), color.alpha(), None),
Some(Fill::Gradient(g)) => (g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g.clone())),
Some(Fill::Gradient(g)) => {
// Colours resolved here (theme, colour map), so the renderer needs no colour scheme.
let mut g = g.clone();
for s in &mut g.stops {
s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
}
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g))
}
Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
_ => (resolve::text_color(ctx, r), 1.0, None),
};
@@ -281,11 +310,13 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
outline,
link: r.link.is_some(),
gradient,
effects: run_effects(ctx, r, scale),
style_name: style_name(bold, italic),
lang: r.lang.clone().filter(|l| !l.is_empty()),
ea,
cs,
major,
kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale),
}
}
@@ -464,6 +495,15 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
st.baseline = 0.0;
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
bullet_face(db, &mut st, &ch);
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
// at U+F000 + the character code as such fonts map them; otherwise common symbol
// bullets are mapped to Unicode look-alikes.
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
Some(c) => c.to_string(),
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
};
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
st.face = db.face("Inter", "Regular");
}
@@ -476,8 +516,10 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
}
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
rp.underline = None;
let st = make_style(ctx, &rp, scale, opts.prompt_color);
Some((autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))), st))
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
bullet_face(db, &mut st, &label);
Some((label, st))
}
_ => None,
}
@@ -485,8 +527,27 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
}
/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
/// and other symbols missing from the text font), by the usual fallback.
fn bullet_face(db: &FontDb, st: &mut Style, label: &str) {
let chars: Vec<char> = label.chars().collect();
if !chars.is_empty() && !deckcraft_fonts::covers_cluster(&st.face, &chars) {
st.face = db.cascade(&chars, std::slice::from_ref(&st.face), st.style_name);
}
}
/// Shape a logical paragraph into cells: bidi levels, per-grapheme faces, items of one level,
/// script, face and shaping style, each shaped with the whole paragraph as context.
/// A run's outer shadow and glow (inner shadows aren't drawn on text), at the layout's `scale`.
fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option<RunEffects> {
let shadow = r.shadow.as_ref().filter(|s| !s.inner).map(|s| {
let a = s.dir.to_radians();
TextShadow { color: ctx.color(&s.color, None), blur: s.blur.max(0.0) * scale, dx: a.cos() * s.dist * scale, dy: a.sin() * s.dist * scale }
});
let glow = r.glow.as_ref().filter(|g| g.radius > 0.0).map(|g| (ctx.color(&g.color, None), g.radius * scale));
(shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow })
}
fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], faces: &mut Vec<Arc<FontFace>>, rtl: bool) -> Vec<Cell> {
let db = FontDb::global();
let chars: Vec<char> = text.chars().collect();
@@ -524,7 +585,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
let (sc, slot) = scripts.get(a).copied().unwrap_or((None, FontSlot::Latin));
// Spaces, digits and punctuation stay in the face of the text before them when it can
// draw them, so a phrase isn't cut into items at every space.
let neutral = cluster.iter().all(|c| script::script_of(*c).is_none());
// Emoji, and pictographs asked to look like text (U+FE0E), pick their own face.
let neutral =
cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}');
let face = match prev.as_ref() {
Some(p) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
_ => {
@@ -553,7 +616,8 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
// Items: maximal ranges of one level, face, script and shaping style (case mapping, language).
let upper = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.caps != Caps::None);
let lang = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).and_then(|st| st.lang.as_deref());
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i));
let kern = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.kern);
let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i));
let mut a = 0;
while a < n {
let mut b = a + 1;
@@ -569,6 +633,24 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
/// Size of superscript and subscript text relative to the run's size.
const SUB_SUPER: f64 = 0.66;
/// The OpenType features to change from the shaper's defaults for an item, as PowerPoint draws
/// it: kerning only where the run asks for it (`kern`), and no standard or contextual ligatures in
/// Latin, Greek and Cyrillic ("fi", "ffi" and Calibri's "ti" stay apart). Other scripts need
/// their ligatures (Arabic lam-alef), and emoji sequences are joined by them.
fn shaping_features(cells: &[Cell], styles: &[Style], script: Option<ScriptTag>) -> Vec<deckcraft_fonts::Feature> {
let mut f = vec![];
if !cells.first().and_then(|c| styles.get(c.style)).is_some_and(|st| st.kern) {
f.extend(deckcraft_fonts::feature("-kern"));
}
let alphabetic = matches!(script.as_ref(), Some(b"Latn" | b"Grek" | b"Cyrl"));
let emoji = cells.iter().any(|c| matches!(c.ch as u32, 0x200D | 0x20E3 | 0xFE0F | 0x2600..=0x27BF | 0x1F000..=0x1FAFF | 0xE0020..=0xE007F));
if alphabetic && !emoji {
f.extend(deckcraft_fonts::feature("-liga"));
f.extend(deckcraft_fonts::feature("-clig"));
}
f
}
/// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the
/// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them).
#[allow(clippy::too_many_arguments)]
@@ -589,7 +671,8 @@ fn shape_item(
let Some(face) = faces.get(fi) else { return };
let rtl = level % 2 == 1;
let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return };
let params = ShapeParams { features: &[], rtl, script, language: lang };
let features = shaping_features(cells.get(range.clone()).unwrap_or(&[]), styles, script);
let params = ShapeParams { features: &features, rtl, script, language: lang };
let glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, &params, |ch| if upper { ch.to_uppercase().next().unwrap_or(ch) } else { ch });
let upem = face.upem.max(1.0);
let scale_of = |cell: &Cell| {
@@ -666,11 +749,59 @@ fn shape_item(
}
}
/// Is `family` a symbol font (characters at U+F000 + code)?
fn is_symbol_font(family: &str) -> bool {
let f = family.to_ascii_lowercase();
f.starts_with("wingdings") || f == "symbol" || f.starts_with("webdings") || f.starts_with("marlett")
}
/// The character to draw bullet `c` with the installed symbol font `face`, when `font` names a
/// symbol font that `face` is (not a substitute) and it has the glyph.
fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option<char> {
let font = font?;
if !is_symbol_font(font) || !face.family.eq_ignore_ascii_case(font) {
return None;
}
let first = c.chars().next()?;
let pua = char::from_u32(0xF000 | ((first as u32) & 0xFF))?;
if face.covers(pua) {
Some(pua)
} else if face.covers(first) {
Some(first)
} else {
None
}
}
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
let f = font.unwrap_or("").to_ascii_lowercase();
let first = c.chars().next().unwrap_or('•');
if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) {
let code = (first as u32) & 0xff;
// Wingdings 2 and 3 put different shapes at the same codes.
if f.starts_with("wingdings 2") {
let m = match code {
0x97 | 0x98 => '●',
0x9e..=0xa1 => '■',
0xa2..=0xa5 => '◆',
0xea..=0xf3 => '★',
0xb7..=0xbf => '○',
0x50 => '✓',
_ => '•',
};
return m.to_string();
}
if f.starts_with("wingdings 3") {
let m = match code {
0x7d | 0x75 => '►',
0x7c | 0x74 => '◄',
0x70 => '▲',
0x71 => '▼',
0xc6 | 0xc7 => '➔',
_ => '▶',
};
return m.to_string();
}
let m = match code {
0xa7 => '▪',
0xd8 => '➢',
@@ -718,8 +849,44 @@ fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) {
(h * face.line_split, h * (1.0 - face.line_split))
}
/// Break opportunities: allowed[i] = a line may start at char i.
fn break_opportunities(text: &str) -> (Vec<bool>, Vec<bool>) {
/// Hangul syllables and jamo: PowerPoint wraps Korean at word boundaries (spaces), not between
/// syllables.
fn is_hangul(c: char) -> bool {
matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}')
}
/// Closing East Asian punctuation that may hang past the end of a line (`hangingPunct`).
fn is_hanging_punct(c: char) -> bool {
matches!(
c,
'、' | '。'
| ','
| '.'
| ':'
| ';'
| '!'
| '?'
| ')'
| '」'
| '』'
| '】'
| '〕'
| '〉'
| '》'
| '〙'
| '〛'
| '}'
| ']'
| '」'
| '、'
| '。'
)
}
/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
/// Korean breaks only between words.
fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>) {
let n = text.chars().count();
let mut allowed = vec![false; n + 1];
let mut mandatory = vec![false; n + 1];
@@ -740,6 +907,17 @@ fn break_opportunities(text: &str) -> (Vec<bool>, Vec<bool>) {
}
}
}
let chars: Vec<char> = text.chars().collect();
for (i, w) in chars.windows(2).enumerate() {
let (prev, next) = (w[0], w[1]);
let keep = if is_hangul(prev) && is_hangul(next) { false } else { latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() };
if !keep
&& !mandatory.get(i + 1).copied().unwrap_or(false)
&& let Some(a) = allowed.get_mut(i + 1)
{
*a = false;
}
}
(allowed, mandatory)
}
@@ -761,7 +939,8 @@ fn break_lines(
x0_rest: f64,
) -> Vec<RawLine> {
let text: String = p.cells.iter().map(|c| c.ch).collect();
let (allowed, _) = break_opportunities(&text);
let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false));
let hang = p.props.hanging_punct.unwrap_or(false);
let n = p.cells.len();
let mut lines = vec![];
let mut start = 0;
@@ -788,6 +967,13 @@ fn break_lines(
if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) {
last_break = Some(i);
}
// Closing East Asian punctuation hangs past the end instead of wrapping
// (`hangingPunct`); the line breaks after it.
if wrap && hang && x + adv > avail + 0.01 && x <= avail + 0.01 && is_hanging_punct(c) && i > start {
end = i + 1;
next = i + 1;
break;
}
if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start {
// Break at the last opportunity, or force mid-word (between clusters).
let b = last_break
@@ -888,7 +1074,9 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
(r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)),
(r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)),
);
let wrap = bp.wrap.unwrap_or(true) && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
// Text that follows a WordArt arch or circle is set on one line per paragraph.
let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow);
let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize;
let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0);
let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0);
@@ -1167,6 +1355,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
link: false,
alpha: st.alpha,
gradient: None,
effects: None,
});
}
// Group visually consecutive cells by (style, face).
@@ -1200,6 +1389,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
link: st.link,
alpha: st.alpha,
gradient: st.gradient.clone(),
effects: st.effects,
});
cur_key = key;
}
+198
View File
@@ -45,6 +45,75 @@ fn bullets_are_drawn_for_body_text() {
assert_eq!(l.para_lines.len(), 2);
}
/// Glyph count and width of `text` laid out in one line of `family` at 40 pt, kern threshold `kern`.
fn glyphs_and_width(family: &str, text: &str, kern: Option<f64>) -> (usize, f64) {
let (p, mut sh) = setup(text, 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
for r in &mut para.runs {
r.props.font = Some(family.into());
r.props.size = Some(40.0);
r.props.kern = kern;
}
let l = lay(&p, &sh, 2000.0, 300.0);
let n = l.runs.iter().map(|r| r.glyphs.len()).sum();
(n, l.lines[0].extent.1 - l.lines[0].extent.0)
}
/// The first installed or bundled family of `families` for which `pick` holds.
fn first_family(families: &[&str], pick: impl Fn(&FontFace) -> bool) -> Option<String> {
let db = FontDb::global();
families.iter().filter(|f| db.has_family(f)).map(|f| db.face(f, "Regular")).find(|f| pick(f)).map(|f| f.family.clone())
}
const LATIN_FAMILIES: &[&str] = &["Carlito", "Calibri", "DejaVu Sans", "Liberation Sans", "Inter", "Arial", "Cambria", "Times New Roman"];
#[test]
fn latin_text_draws_no_ligatures() {
// PowerPoint keeps "fi", "ffi" and Calibri's "ti" apart, although the fonts have them as
// standard ligatures.
let ligates = |f: &FontFace| deckcraft_fonts::shape(f, "office", &[], |c| c).len() < 6;
let Some(family) = first_family(LATIN_FAMILIES, ligates) else {
eprintln!("skipped: no font with an ffi ligature");
return;
};
let (n, _) = glyphs_and_width(&family, "office fifth", Some(0.0));
assert_eq!(n, 12, "{family}: one glyph per character");
}
#[test]
fn pairs_are_kerned_only_from_the_kern_size() {
let kerns = |f: &FontFace| {
let w = |feat: &[deckcraft_fonts::Feature]| deckcraft_fonts::shape(f, "AVATAR", feat, |c| c).iter().map(|g| g.x_advance).sum::<i32>();
w(&[]) != w(&deckcraft_fonts::feature("-kern").into_iter().collect::<Vec<_>>())
};
let Some(family) = first_family(LATIN_FAMILIES, kerns) else {
eprintln!("skipped: no font that kerns AVATAR");
return;
};
// `kern` is the smallest size kerned; 0 is no kerning.
let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0));
let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0));
let (_, at) = glyphs_and_width(&family, "AVATAR WAVE", Some(40.0));
let (_, above) = glyphs_and_width(&family, "AVATAR WAVE", Some(48.0));
assert!(on < off - 2.0, "{family}: kerned {on} vs {off}");
assert!((at - on).abs() < 0.01, "kerned at exactly the threshold size");
assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold");
}
#[test]
fn ligatures_stay_for_scripts_that_need_them() {
let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag));
let cells =
|s: &str| s.chars().map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true }).collect::<Vec<_>>();
let latin = shaping_features(&cells("office"), &[], Some(*b"Latn"));
assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern"));
// Arabic needs its ligatures; emoji sequences are joined by them.
assert!(!has(&shaping_features(&cells("\u{644}\u{627}"), &[], Some(*b"Arab")), "-liga"));
assert!(!has(&shaping_features(&cells("a\u{1F469}\u{200D}\u{1F4BB}"), &[], Some(*b"Latn")), "-liga"));
assert!(!has(&shaping_features(&cells("12"), &[], None), "-liga"));
}
#[test]
fn only_the_first_character_of_a_bullet_is_drawn() {
// "••" (seen in the wild) draws one dot in PowerPoint.
@@ -520,3 +589,132 @@ fn single_spaced_lines_are_1_2_times_the_size_apart_whatever_the_font() {
assert!((first.baseline - first.top - size * LINE_PITCH * face.line_split).abs() < 1e-6);
}
}
/// The lines of `text` in an Arial-free (whatever face resolves) box `w` wide, as strings.
fn wrapped(text: &str, w: f64, set: impl Fn(&mut ParaProps)) -> Vec<String> {
let (p, mut sh) = bidi_setup(vec![Run::new(text)], w, false, Align::Left);
let t = sh.text.as_mut().unwrap();
t.body.inset_l = Some(0.0);
t.body.inset_r = Some(0.0);
set(&mut t.paragraphs[0].props);
let l = lay(&p, &sh, w, 600.0);
let chars: Vec<char> = text.chars().collect();
l.lines.iter().map(|li| chars[li.start..li.end].iter().collect()).collect()
}
/// Width of `text` on one line.
fn one_line(text: &str) -> f64 {
let (p, sh) = bidi_setup(vec![Run::new(text)], 5000.0, false, Align::Left);
let l = lay(&p, &sh, 5000.0, 300.0);
l.lines[0].caret_x.last().unwrap() - l.lines[0].caret_x[0]
}
#[test]
fn a_word_does_not_break_after_a_slash_or_backslash() {
// PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it
// where it no longer fits; Unicode line breaking would allow a break after each slash.
for sep in ["/", "\\"] {
let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep);
let w = one_line(&text) * 0.6;
let lines = wrapped(&text, w, |_| {});
assert!(lines.len() >= 2, "{lines:?}");
assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}");
}
// latinLnBrk on: Unicode's break after a slash is allowed again.
let text = "alpha/beta/gamma/delta/epsilon";
let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true));
assert!(lines[0].ends_with('/'), "{lines:?}");
// A slash followed by a space still breaks, and hyphens still break.
let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {});
assert_eq!(lines[0], "alpha-beta-gamma-");
}
#[test]
fn korean_wraps_between_words() {
let text = "한국어 안녕하세요 반갑습니다 감사합니다";
let w = one_line("한국어 안녕하세요 반") + 0.5;
let lines = wrapped(text, w, |_| {});
assert_eq!(lines.iter().map(|l| l.trim_end()).collect::<Vec<_>>(), ["한국어 안녕하세요", "반갑습니다", "감사합니다"], "{lines:?}");
// Japanese and Chinese still break between characters.
let lines = wrapped("日本語の文章は空白なしで続きます", one_line("日本語の文章") + 0.5, |_| {});
assert_eq!(lines[0], "日本語の文章");
}
#[test]
fn closing_punctuation_hangs_past_the_end_with_hanging_punct() {
// PowerPoint (hangingPunct on, as its default text styles set it) lets "。" hang past the
// edge instead of pulling the character before it onto the next line.
let text = "白なしで続きます。句読点も含めて。";
let w = one_line("白なしで続きます") + 0.5;
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
assert_eq!(lines[0], "白なしで続きます。", "{lines:?}");
// Without it, "。" can't start a line, so "す。" moves down together.
let lines = wrapped(text, w, |p| p.hanging_punct = Some(false));
assert_eq!(lines[0], "白なしで続きま", "{lines:?}");
}
#[test]
fn emoji_take_a_colour_face_when_one_is_installed() {
// PowerPoint draws emoji (and ZWJ sequences, skin tones, flags) from the colour emoji font
// even when the run's font has a plain glyph for some of them. Skipped without an installed
// colour emoji font.
let db = FontDb::global();
let probe = db.cascade(&['😀'], &[], "Regular");
if !probe.is_color() {
return;
}
for s in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧"] {
let (p, sh) = bidi_setup(vec![Run::new(format!("a{s}b"))], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let emoji = l.runs.iter().find(|r| r.cells.iter().any(|(_, c)| !c.is_ascii())).unwrap();
assert!(emoji.face.is_color(), "{s:?} drawn with {}", emoji.face.family);
assert!(l.runs.iter().filter(|r| r.cells.iter().all(|(_, c)| c.is_ascii())).all(|r| !r.face.is_color()), "the letters keep a text face");
}
}
#[test]
fn a_bullet_the_text_font_lacks_falls_back_to_a_face_that_has_it() {
// circleNumDbPlain numbers ①②…: the text font usually has no circled digits; PowerPoint draws
// them from a fallback font instead of a missing-glyph box. Skipped when no installed or
// bundled face has ①.
let db = FontDb::global();
if !db.cascade(&['①'], &[], "Regular").covers('①') {
return;
}
let (p, mut sh) = setup("One\nTwo", 600.0, 300.0);
for para in &mut sh.text.as_mut().unwrap().paragraphs {
para.props.bullet = Some(Bullet::AutoNum { scheme: "circleNumDbPlain".into(), start_at: 1 });
}
let l = lay(&p, &sh, 600.0, 300.0);
let bullets: Vec<_> = l.runs.iter().filter(|r| r.cells.is_empty()).collect();
assert_eq!(bullets.len(), 2);
for b in bullets {
assert!(b.face.covers('①'), "drawn with {}, which lacks the circled digit", b.face.family);
assert!(b.glyphs.iter().all(|g| g.0 != 0), "no missing-glyph box");
}
}
#[test]
fn symbol_font_bullets_map_by_font() {
// Wingdings 2 code 0xEA is a star, Wingdings 3 0x7D a right-pointing triangle; plain
// Wingdings keeps its own table (0xA7 is a small square).
let star = String::from_utf8(vec![0xEF, 0x83, 0xAA]).unwrap(); // U+F0EA
assert_eq!(map_symbol_bullet(&star, Some("Wingdings 2")), "★");
assert_eq!(map_symbol_bullet("\u{7d}", Some("Wingdings 3")), "►");
assert_eq!(map_symbol_bullet("\u{f0a7}", Some("Wingdings")), "▪");
assert_eq!(map_symbol_bullet("•", Some("Arial")), "•");
assert!(is_symbol_font("Wingdings 2") && is_symbol_font("Symbol") && !is_symbol_font("Arial"));
}
#[test]
fn an_installed_symbol_font_draws_its_own_bullet() {
// Where the deck's symbol font is installed, its glyph is drawn (as PowerPoint does), not a
// look-alike. Skipped without Wingdings 2.
let db = FontDb::global();
if !db.has_family("Wingdings 2") {
return;
}
let face = db.face("Wingdings 2", "Regular");
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")), Some('\u{f0ea}'));
assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None);
}
+1 -1
View File
@@ -15,7 +15,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
| Layer | Crates | What they do |
|---|---|---|
| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~150 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder |
| L0 | `geom`, `color`; standalone `bitstream`, `matroska`, `ogg`, `opus` | EMU/pt units, transforms, ~180 preset shape generators re-derived from ECMA-376, adjust handles, hit testing; RGB/HSL and DrawingML colour transforms; MKV/WebM and Ogg demuxers, the Opus decoder |
| L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer |
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
+5 -4
View File
@@ -38,7 +38,7 @@ PowerPoint opens and saves; Viewer = opens only).
|---|---|---|---|
| PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 |
| SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted |
| EMF / WMF | yes | **no** | Common in decks made on Windows: blank picture |
| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, bitmaps); text records inside are skipped |
| HEIC | yes (macOS) | no | |
| Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` |
| WMA | yes (Windows) | embedded, not playable | |
@@ -63,13 +63,13 @@ the round trip even though DeckCraft doesn't model it.
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
| Notes slides and notes master | yes | yes | No notes-master editing view |
| Handout master | no | no | |
| Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` |
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`) |
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
| 3-D: `scene3d`, `sp3d` | kept | kept | Not rendered |
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow` and date fields |
| Text warp (`prstTxWarp`) | yes | yes | Not rendered |
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
| Tables with styles | yes | yes | |
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` |
@@ -93,10 +93,11 @@ the round trip even though DeckCraft doesn't model it.
embedded fonts, every transition and animation) in a corpus repo, with import → render → export
→ re-import checks in CI and a PowerPoint reference render for each (owner runs PowerPoint).
2. The round-trip losses above (#70, #74, #77).
3. EMF/WMF and SVG rendering.
3. SVG rendering, and text inside EMF/WMF.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
+16 -11
View File
@@ -20,7 +20,7 @@ revision history.
| 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) |
| 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file |
| 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) |
| 4 | EMF/WMF and SVG pictures don't render | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) |
| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) |
| 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) |
| 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) |
| 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) |
@@ -75,14 +75,14 @@ Detail in [file-format-parity.md](file-format-parity.md).
- **Impact:** a deck opened and saved silently loses review state and show behaviour.
- **Estimate:** 3–6 h. **Beta blocker.**
### EMF/WMF and SVG pictures don't render
### SVG pictures don't render
- **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF,
TIFF, BMP); nothing reads EMF, WMF or SVG. Decks from Windows often hold EMF (pasted charts,
Visio, logos); PowerPoint's Insert Icons stores SVG with a PNG fallback.
- **Impact:** blank pictures in real decks; SVG icons show only their raster fallback, and SVG files
can't be inserted.
- **Estimate:** 8–14 h (an SVG renderer through `resvg`-style crates, an EMF/WMF record player for
the common records). **Beta blocker** for EMF.
TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens
and bitmaps; text records are skipped). Nothing reads SVG; PowerPoint's Insert Icons stores SVG
with a PNG fallback.
- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. Text
drawn inside an EMF/WMF is missing.
- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF text records 2–4 h.
### Template, slide-show and macro variants are saved as plain `.pptx`
- **Evidence:** `crates/pptx/src/write/mod.rs` always writes the
@@ -165,9 +165,13 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a *
- **Estimate:** 4–6 h.
### Edit points, text warp, 3-D
- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms", "Bevel and 3-D
rotation" (missing; `scene3d`/`sp3d` kept as raw XML on round trip).
- **Estimate:** 4–6 h (edit points), 6–10 h (warp), 8–12 h (3-D render approximation).
- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40
`prstTxWarp` presets render and their adjusts round-trip, but there is no Transform gallery or
command to apply one, no adjust handles, and arc/circle placement can differ from PowerPoint by
1–3 % of the path length), "Bevel and 3-D rotation" (missing; `scene3d`/`sp3d` kept as raw XML on
round trip).
- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 8–12 h (3-D render
approximation).
### Proofing: spelling, thesaurus, AutoCorrect
- **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings
@@ -247,5 +251,6 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
+3 -3
View File
@@ -9,7 +9,7 @@ The summary between `<!-- SUMMARY -->
|---|---|---|---|---|---|
| P0 | 72 | 60 | 12 | 0 | 92% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P2 | 54 | 13 | 13 | 28 | 36% |
| P2 | 54 | 13 | 14 | 27 | 37% |
| Area | Parity |
|---|---|
@@ -18,7 +18,7 @@ The summary between `<!-- SUMMARY -->
| Slides | 90% |
| Views | 90% |
| Masters & themes | 85% |
| Shapes | 86% |
| Shapes | 88% |
| Format | 81% |
| Arrange | 100% |
| Text | 80% |
@@ -115,7 +115,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh
| Shapes | Change shape | P1 | D |
| Shapes | Text boxes | P0 | D |
| Shapes | WordArt | P1 | P |
| Shapes | Text warp transforms | P2 | M |
| Shapes | Text warp transforms | P2 | P |
| Shapes | Shape quick styles | P1 | D |
| Shapes | Set as default shape | P2 | D |
| Format | Solid fill and transparency | P0 | D |
+1 -1
View File
@@ -16,7 +16,7 @@ points and lead the beta plan.
| Core workflow | Works end to end? | Evidence | Hours to full pass |
|---|---|---|---|
| Build a deck from scratch (theme, layouts, titles and bullets, shapes, pictures from file), save, close and reopen it | yes | Commands for all of it; `.deckcraft` and `.pptx` round-trip tests (`crates/pptx/tests/roundtrip.rs`, `crates/format`); AutoRecover. Close discards edits without asking (#60, PR #75); image *paste* fails on Windows/Linux (#53), though insert-from-file works | 1–3 |
| Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; EMF/WMF pictures render blank; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 |
| Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; text inside EMF/WMF pictures is missing; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 |
| Present: full-screen show with transitions, animations, media, presenter view with notes, next slide and timer | yes | 48/48 transitions, 64 effects, triggers, builds, media playback, presenter window. Presenter view isn't placed on the second display automatically (drag it there) | 3–5 |
| Add business content: tables, charts with their data, simple diagrams | partial (not blocking) | Tables with styles, merge/split, all D or P. Charts insert with the common types and edit data in the Chart Data dialog (P0 partial); SmartArt is basic (pre-drawn shapes) | 12–20 |
| Share the deck: PDF of slides, notes or handouts; images | yes | `file.export` PDF with text layer, links, bookmarks, handouts 1–9; PNG/JPEG. Artwork is raster in the PDF; no direct print (print the PDF) | 10–16 |
+1 -1
View File
@@ -212,7 +212,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc
header/footer fields, embedded media, unlimited undo on shared slide snapshots.
- **Themes:** 8 original themes with colour and font schemes, custom colours and fonts,
backgrounds (solid, gradient, picture, pattern), slide size presets.
- **Shapes:** ~150 preset geometries with adjustment handles, text boxes, pictures (crop,
- **Shapes:** ~180 preset geometries with adjustment handles, text boxes, pictures (crop,
corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line,
area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites,
freeform/curve/scribble, merge shapes, ink, action buttons, WordArt; flipped shapes keep their
+2
View File
@@ -47,6 +47,8 @@ pub const TABLE: &[(&str, Class)] = &[
("isobmff", Class::Layer(1)),
("media", Class::Layer(2)),
("color", Class::Layer(0)),
// WMF/EMF reader (ported from WordCraft).
("metafile", Class::Layer(0)),
("model", Class::Layer(1)),
("fonts", Class::Layer(1)),
("text", Class::Layer(2)),