Compare commits

...

21 Commits

Author SHA1 Message Date
Brandon Thomas c2cc712156 Docs: keep the progress table in one piece 2026-10-13 06:48:29 -04:00
Brandon Thomas c26c564a6f Docs: progress log for the wild text cases
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:04 -04:00
Brandon Thomas f495922c4e Fonts: a letter with combining marks the run's font lacks takes a face that has them all
"Z̴̡̛͓a̸̧̘l̵̩g̶̞o̸̤" in an Aptos run drew its marks as missing-glyph boxes: no face in the chain
covered the whole cluster and the fallback for its base letter neither, so the run's own font was
kept. PowerPoint draws the marks stacked. A loaded plain face that covers the whole cluster is
now taken before falling back to the run's font. w09-scripts s1 8.40 -> 7.86.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:03 -04:00
Brandon Thomas c858c11bf6 Text: rtlCol puts the first column on the right; just/dist anchoring at the bottom; wordArtVertRtl
Observed on PowerPoint's export (w14-xml s12):
- a two-column body with rtlCol="1" starts its first column on the right; DeckCraft read and
  saved the attribute but laid columns out left to right;
- anchor="just" and anchor="dist" place the text at the bottom of the box, like anchor="b";
  DeckCraft drew them at the top;
- wordArtVertRtl stacks characters like wordArtVert but runs its lines from right to left; it was
  read as wordArtVert (and saved back as it). It is now its own TextDir, kept on save.

w14-xml s12 9.47 -> 5.39.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:03 -04:00
Brandon Thomas 973af106bb Text: strikethrough at the font's strikeout position and thickness
The strikethrough was drawn 0.3 em above the baseline, 0.05 em thick, for every font. PowerPoint
uses the font's own strikeout metrics (OS/2): Arial 96 pt strikes rows 347-351, 24.8 px above the
baseline (530/2048 em) and 4.8 px thick; DeckCraft drew 343-347. Faces now carry their strikeout
metrics (0.26/0.05 em when a font has none) and the line matches PowerPoint's rows exactly.
w14-xml s11 4.06 -> 3.24.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:03 -04:00
Brandon Thomas bde32dfcf1 Fonts: only steer a symbol away from the colour font when that is the face it would get
The previous change sent every text-presentation pictograph to a symbol font, which also moved
bullets such as "▪" (Wingdings substitutes) from the face they had to a wider one: necc-
presentation-0 s13 18.78 -> 19.72. Now a pictograph is redirected to a plain face only when its
fallback would be the colour emoji font; keycaps and U+FE0E sequences still take a plain face.
necc s12-s14 back to 26.38 / 18.78 / 20.91; w14-xml s7 (pictographs) 9.79 -> 7.49 overall.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:03 -04:00
Brandon Thomas 3bab2e4e4f Text: character spacing leaves kerning on again (only Arial drops it in PowerPoint)
A wider probe (Arial, Aptos, Calibri, Segoe UI, Times New Roman, Verdana at 40 pt, each box with
one paragraph spaced 2 pt) shows the earlier rule holds for Arial alone: Calibri, Segoe UI and
Times New Roman keep "AVATAR" kerned beside and inside spaced runs (121 vs 131 px for Calibri).
The rule made spaced Aptos and Calibri text too wide (w15-mixed s9 5.46 -> 7.86), so it is
removed; Arial's behaviour stays a known difference.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:03 -04:00
Brandon Thomas af9a520981 Text: a fallback face (emoji, symbols, a missing script) no longer moves the line's baseline
Line height and baseline were the largest over every face on the line, fallback faces included,
so a line with an emoji, a math symbol or Thai in an Aptos run sat 1-5 px lower than PowerPoint's
and pushed the lines below it down. PowerPoint keeps the run's own font metrics (measured: the
second line of six boxes starts at the same height whatever the first line holds). Faces that are
the run's Latin, East Asian, complex-script or symbol font still count.

w14-xml s8 (baselines) 5.54 -> 3.21, w09-scripts s1 10.27 -> 8.39, s2 9.37 -> 7.46,
w15-mixed s8 13.65 -> 8.77; w15-mixed s2 (Arabic) 6.57 -> 7.05.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:03 -04:00
Brandon Thomas b828bfc321 Fonts: emoji presentation as PowerPoint draws it; keycaps and text pictographs in a symbol font
Observed on PowerPoint's export of pictographs in Aptos, Arial and Segoe UI:
- characters that default to emoji presentation draw in colour without a selector: ⭐ ✅ ☔ ⌚ ⏩
  and the other BMP Emoji_Presentation characters (DeckCraft only knew the supplementary-plane
  ones and drew ⭐ ✅ as text);
- text-presentation pictographs (⚠ ♥ ✔ ☎ ⬆ ➡ ▶ ⤴) draw as text even where the colour font is the
  first installed font that has them: they now come from a plain symbol font, symbol families
  first, never the colour one; and a symbol after an emoji no longer inherits its colour face;
- keycap sequences ("1️⃣", "#⃣") draw as text, an outlined key, not the colour keycap.

w14-xml s7 (pictographs) 9.79 -> 6.16, w09-scripts s1 (emoji) 11.56 -> 10.27.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:03 -04:00
Brandon Thomas de85c8bdbe Text: mongolianVert and wordArtVert (stacked) text as PowerPoint draws them
mongolianVert was read as vert (and saved back as vert). PowerPoint turns it like vert but runs
its lines from left to right, the anchor mirrored with them (bottom-anchored text sits on the
right). It is now its own TextDir, kept on save; its lines' places are mirrored across the box.

wordArtVert (Stacked) was turned like vert. PowerPoint stands every character upright, one
under the other (Latin letters about one em apart, spaces their own width), with lines running
left to right. Stacked text now stands each character upright in an em of its own and mirrors
the line order.

Also: a bullet in the second or later column of a multi-column body is drawn on its line's
baseline (it kept the first column's).

w01-vertical s4 (mongolianVert) 11.54 -> 9.38, s5 (wordArtVert) 13.84 -> 13.56; w14-xml s13
6.78 -> 6.60.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:03 -04:00
Brandon Thomas 1b1b21210a Text: East Asian vertical text (eaVert) stands ideographs and kana upright
eaVert text was laid out exactly like vert: the whole text turned a quarter turn, so Japanese and
Chinese lay on their side. PowerPoint draws ideographs, kana, Hangul, full-width forms, CJK
punctuation, circled numbers and emoji upright, each turned back about the centre of its em box,
while Latin letters and digits stay on their side ("英字123も": 123 sideways). It keeps the glyphs'
horizontal forms (no vert alternates: "、" "「" are simply turned), which DeckCraft now matches.

Glyph runs carry an `upright` flag; the renderer turns each such glyph about its em box centre
(0.38 em above the baseline). w01-vertical s3 (eaVert) 9.70 -> 8.98, s6/s7 (eaVert rotated,
flipped, bulleted) 1.58 -> 1.45, 2.30 -> 1.76.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:02 -04:00
Brandon Thomas bbaa1aa7b1 Text: East Asian, Thai, Hindi, Arabic and Hebrew auto-number schemes
Every scheme other than the Latin, Roman and circled ones drew Arabic digits ("4." for
ea1ChsPeriod, "- 10 -" for arabic2Minus). Each is now written as PowerPoint's export draws it
(probe deck: one box per a:buAutoNum type and start value): Chinese numerals for ea1Chs/ea1Cht
(十, 十一, 二十一), digit-by-digit kanji with 〇 for ea1Jpn* (一〇), full-width digits for
arabicDb*, a full-width full stop after the East Asian ones, Thai letters (without ฃ ฅ ฆ) and
digits, Devanagari vowels, consonants and digits, the Arabic alphabet (arabic1Minus) and abjad
(arabic2Minus) and the Hebrew alphabet with a hyphen, and the Wingdings-style circles that go to
ten (circleNumWdWhite 13 → ③, circleNumWdBlack → ❶…). w04-lists s2-s4 drew wrong labels in 24 of
36 boxes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:02 -04:00
Brandon Thomas 5a64fdbc3e Text: Japanese line breaking keeps small kana and "ー" off line starts; only commas and stops hang
Observed on PowerPoint's export of a wrapped Japanese paragraph (24 pt, 11 characters a line):
it breaks "けません(禁則処" / "理)。ー長音もで" / "す。っゃゅょ小書き文字", i.e. small kana
(っゃゅょ…), the prolonged sound mark and iteration marks never start a line (strict kinsoku),
where UAX #14's default allows it. And "し、句読点「、」や" / "「。」は…": with hanging punctuation on,
a closing bracket that overflows does not hang; only the ideographic comma and full stop (and
their full-width and half-width forms) do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:02 -04:00
Brandon Thomas 6c4bcedbbc Text: soft hyphens show a hyphen where a line breaks; no break after zero-width space or before em dash
Observed on PowerPoint's export of long joined words:
- a line broken at a soft hyphen (U+00AD) ends with a visible hyphen ("Softhyphenatedwordsina-");
  DeckCraft broke there but drew nothing. The hyphen now takes its room on the line;
- PowerPoint does not break after a zero-width space (it splits the word where it overflows
  instead), where UAX #14 allows a break;
- an em dash ends a line but never starts one ("Em—dash—joined—" / "words—that—are—long—"),
  where UAX #14 allows a break before it too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:02 -04:00
Brandon Thomas f5d055b919 Text: condensed character spacing keeps pairs kerned
Follow-up to the kerning rule: on the real corpus (ms6: titles condensed by 1 pt from the
master's title style) PowerPoint kerns condensed text, and a closer probe agrees for -0.6, -1,
-1.5, -2 and -3 pt (only -1.2 and -2.5 pt came out unkerned, which no simple rule explains). Only
spacing out by 0.6 pt or more now switches kerning off for the box. ms6 slides 2/7: 4.97 -> 6.44
-> back to 4.97, 7.05 -> 8.10 -> 7.05.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:02 -04:00
Brandon Thomas 8561a83819 Text: underline styles (double, heavy, dotted, dashed, wavy) at the font's underline position
Every underline was a solid line 0.06 em thick at 0.12 em below the baseline, whatever a:rPr/@u
asked for. Underlines now sit at the font's own underline position and thickness (post table),
where PowerPoint draws them (Arial 96 pt: 10 px below the baseline, 7 px thick), and each style
is drawn as measured on PowerPoint's export: dbl two lines 0.55 thick spanning one thickness above
to the single line's bottom; heavy from one thickness above to 0.55 below; dotted dots and gaps one
thickness long; dashes three long with gaps of two; wavy a thin zigzag every 3.9 thicknesses. The
"words" style underlines spaces too, as PowerPoint draws it. Dashes and waves are new Deco line
kinds the renderer fills or strokes (warped text draws their boxes).

Underline slides: w14-xml s10 11.49 -> 10.43, s11 6.64 -> 4.06; w06-charfmt s3 9.66 -> 9.51,
s4 7.63 -> 7.44.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:02 -04:00
Brandon Thomas 7f4db9db64 Text: centre, right and decimal tab stops align the text after them; a tab past the end wraps
Tab stops were all left stops: a:tab/@algn was read and saved but ignored. As PowerPoint draws
them, the text after a tab (up to the next tab or line break) now ends at a right stop, is
centred on a centre stop, and has its first "." on a decimal stop (text without one ends there);
when that text is wider than the room before the stop it starts where the tab is, never further
left. Measured on PowerPoint's export: "right" ends at 180 pt, "12.5" and "1234.567" put the
point on the stop, "12,5" and "nodot" end at it.

A tab whose stop lies past the end of the line now starts the next line, so the text after it
sits on that line's first stop, as PowerPoint wraps "abcdefghij<tab>k<tab>l<tab>m…".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:02 -04:00
Brandon Thomas 696529a257 Text: transparent text fills are faded once, not twice
A run's solid fill with a:alpha was resolved into the glyph colour's alpha and also kept as the
run's fill opacity, and the renderer multiplied both: text at 60 % transparency drew at 16 %
opacity instead of 40 %. The colour now carries the transparency alone. PowerPoint's export of
"Transparent 60% text" (red) is a light red that now matches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:02 -04:00
Brandon Thomas 2f4c78e149 Text: small capitals draw at their size, and all caps keep characters like "ß"
Small capitals were drawn at the full size of the run's first character: glyph runs grouped
cells by style and face only, so "HxHx" drew every letter at the capital's size on the small
capitals' narrower advances (letters overlapped). A lowercase letter in small caps now starts a
run of its own at 80 % (PowerPoint's small capital "X" is 0.81 of the capital "H" in Arial).

All caps and small caps mapped "ß" to "S" (the first character of "SS"); PowerPoint leaves a
character whose capital is a sequence as it is ("ß", "fi", "ʼn"), and maps the rest one to one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:02 -04:00
Brandon Thomas a6eeb38cfd Text: character spacing of 0.6 pt or more (or -1 pt) switches kerning off for the whole box
PowerPoint kerns pairs (a:rPr/@kern) only while no run of the text body is spaced out by 0.6 pt
or more or condensed by 1 pt or more; then no run of that box is kerned, even runs without
spacing. Measured on Arial 40 pt "AVATAR": spacing 0, 0.01, 0.5, -0.6 and -0.99 pt keep the pairs
(146 px wide), 0.6, 0.8, 1, 1.5 and -1 pt drop them in every paragraph of the box (158 px plus the
spacing). DeckCraft kerned spaced titles, making "AVATAR"-like words 12 px too narrow at 40 pt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:02 -04:00
Brandon Thomas 324756ae5d Text: bidi resolves spaces, punctuation and digits by the run's language, as PowerPoint does
PowerPoint does not resolve neutral characters with plain UAX #9: the language of each run
(a:rPr/@lang) decides. Measured with every space highlighted in its own colour:
- in a run of a left-to-right language (or none) punctuation and digits are left to right and a
  space takes the direction of the character before it, so "abc HE1 HE2 def" shows the space after
  the Hebrew words on their left, and an English sentence in an RTL paragraph keeps its full stop
  at its end;
- in a Hebrew/Arabic run, neutrals between text of one direction take it, all others are right
  to left, and digits are a number in the right-to-left text unless left-to-right text precedes.

Paragraphs with explicit embeddings keep the full algorithm. Bidi probe deck: mean diff 11.1 ->
10.0 on the four mixed-direction slides; corpus poi 6.660 -> 6.654, others unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 06:46:02 -04:00
13 changed files with 1562 additions and 115 deletions

View File

@ -129,6 +129,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
@ -148,6 +149,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |

View File

@ -17,6 +17,11 @@ use skrifa::raw::TableProvider;
use crate::script::{ScriptTag, script_of};
use crate::{CRAFT_FONTS, FontDb, FontFace};
/// Variation selector 15: text presentation.
const TEXT_SELECTOR: char = '\u{FE0E}';
/// Combining enclosing keycap.
const KEYCAP: char = '\u{20E3}';
/// Characters that never decide a face: they're drawn by whatever face the cluster gets.
fn ignorable(c: char) -> bool {
c.is_whitespace()
@ -24,20 +29,72 @@ 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?
/// Is the cluster an emoji drawn in colour: a pictograph that defaults to emoji presentation
/// (Unicode's Emoji_Presentation, including BMP ones such as ⭐ ✅ ☔ ⌚ ⏩), or a sequence with an
/// emoji presentation selector (U+FE0F), a zero-width joiner between pictographs, a skin tone
/// modifier or regional indicators (flags)? Keycaps ("1️⃣") are not: PowerPoint draws them as text
/// (an outlined key), as it draws ♥ ✔ ☎ ⬆ ▶ without a selector.
pub fn is_emoji_cluster(cluster: &[char]) -> bool {
if cluster.contains(&'\u{FE0E}') {
if cluster.contains(&'\u{FE0E}') || cluster.contains(&'\u{20E3}') {
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);
let default_emoji = |c: char| {
matches!(
c as u32,
0x1F300..=0x1F64F
| 0x1F680..=0x1F6FF
| 0x1F900..=0x1F9FF
| 0x1FA70..=0x1FAFF
| 0x1F1E6..=0x1F1FF
| 0x1F004
| 0x1F0CF
| 0x1F18E
| 0x1F191..=0x1F19A
| 0x1F201
| 0x1F21A
| 0x1F22F
| 0x1F232..=0x1F236
| 0x1F238..=0x1F23A
| 0x1F250..=0x1F251
| 0x231A..=0x231B
| 0x23E9..=0x23EC
| 0x23F0
| 0x23F3
| 0x25FD..=0x25FE
| 0x2614..=0x2615
| 0x2648..=0x2653
| 0x267F
| 0x2693
| 0x26A1
| 0x26AA..=0x26AB
| 0x26BD..=0x26BE
| 0x26C4..=0x26C5
| 0x26CE
| 0x26D4
| 0x26EA
| 0x26F2..=0x26F3
| 0x26F5
| 0x26FA
| 0x26FD
| 0x2705
| 0x270A..=0x270B
| 0x2728
| 0x274C
| 0x274E
| 0x2753..=0x2755
| 0x2757
| 0x2795..=0x2797
| 0x27B0
| 0x27BF
| 0x2B1B..=0x2B1C
| 0x2B50
| 0x2B55
)
};
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{FE0F}') && cluster.iter().any(|&c| pictographic(c)))
|| (cluster.contains(&'\u{200D}') && cluster.iter().any(|&c| pictographic(c)))
|| cluster.contains(&'\u{20E3}')
}
/// Does `face` draw every character of the cluster?
@ -86,12 +143,13 @@ impl FontDb {
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();
}
// Text presentation (U+FE0E) and keycaps: a plain face, not a colour emoji one.
let text_style = cluster.contains(&TEXT_SELECTOR) || cluster.contains(&KEYCAP);
if text_style
&& let Some(key) = cluster.iter().copied().find(|c| !ignorable(*c))
&& let Some(f) = self.plain_face_for(key, cluster)
{
return f;
}
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() };
@ -124,11 +182,23 @@ impl FontDb {
}
}
let exclude = chain.first().map(|f| f.id()).unwrap_or(u32::MAX);
match self.fallback_for(key, exclude) {
let f = match self.fallback_for(key, exclude) {
Some(f) if covers_cluster(&f, cluster) => f,
Some(f) if chain.iter().all(|c| !c.covers(key)) => f,
_ => first(),
// A letter with marks the run's font lacks (stacked combining marks): a loaded plain
// face that draws the whole cluster, as PowerPoint draws the marks, before the run's
// font with missing-glyph boxes.
_ => self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned().unwrap_or_else(first),
};
// A pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) whose first fallback is the
// colour emoji font: PowerPoint draws it from a plain symbol font.
if f.is_color()
&& !is_emoji_cluster(cluster)
&& let Some(plain) = self.plain_face_for(key, cluster)
{
return plain;
}
f
}
/// An installed (non-bundled) face covering the cluster, loading one from the system catalog

View File

@ -134,6 +134,12 @@ pub struct FontFace {
/// Windows ascent over the Windows ascent plus descent (OS/2 `usWinAscent`/`usWinDescent`),
/// the split PowerPoint uses (computed by `line_split`).
pub line_split: f64,
/// Underline position (top edge, below the baseline) and thickness in font units (`post`
/// table; 0.1 and 0.05 em when the font has none). PowerPoint draws single underlines there.
pub underline: (f64, f64),
/// Strikethrough position (top edge, above the baseline) and thickness in font units (OS/2;
/// 0.26 and 0.05 em when the font has none). PowerPoint draws single strikethroughs there.
pub strikeout: (f64, f64),
pub shaper: harfrust::ShaperData,
/// Variable fonts: the named instance's axis settings (user units; empty = default instance),
/// the normalised location and the shaper's view of it.
@ -586,6 +592,8 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
line_split: line_split(&f, m.ascent as f64, -(m.descent as f64), m.units_per_em.max(1) as f64),
underline: underline_metrics(m.underline, m.units_per_em.max(1) as f64),
strikeout: strikeout_metrics(m.strikeout, m.units_per_em.max(1) as f64),
shaper,
coords,
location,
@ -597,6 +605,30 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
})
}
/// The underline's top edge below the baseline and its thickness, font units, from the `post`
/// table (its position is the top edge: Arial's -217/2048 is where PowerPoint's underline starts,
/// 10 px below the baseline at 96 pt); fonts without a usable one get 0.1 em and 0.05 em.
fn underline_metrics(d: Option<skrifa::metrics::Decoration>, upem: f64) -> (f64, f64) {
match d {
Some(d) if d.thickness > 0.0 && (d.thickness as f64) < upem * 0.3 && (d.offset as f64).abs() < upem * 0.5 => {
(-(d.offset as f64), d.thickness as f64)
}
_ => (upem * 0.1, upem * 0.05),
}
}
/// The strikethrough's top edge above the baseline and its thickness, font units, from OS/2 (its
/// position is the top edge: Arial's 530/2048 is where PowerPoint's strikethrough starts, 25 px
/// above the baseline at 96 pt); fonts without a usable one get 0.26 em and 0.05 em.
fn strikeout_metrics(d: Option<skrifa::metrics::Decoration>, upem: f64) -> (f64, f64) {
match d {
Some(d) if d.thickness > 0.0 && (d.thickness as f64) < upem * 0.3 && d.offset > 0.0 && (d.offset as f64) < upem => {
(d.offset as f64, d.thickness as f64)
}
_ => (upem * 0.26, upem * 0.05),
}
}
/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`).
pub fn base_style(style: &str) -> &str {
style.split('{').next().unwrap_or(style).trim()
@ -1060,6 +1092,79 @@ impl FontDb {
None
}
/// A loaded plain (not colour) face drawing all of `cluster`, loading an installed plain font
/// that covers `key` if none is loaded yet. Misses are remembered.
pub(crate) fn plain_face_for(&self, key: char, cluster: &[char]) -> Option<Arc<FontFace>> {
// Symbol fonts first (PowerPoint draws ⚠ ♥ ⬆ from the system's symbol font), then the
// usual fallback families in order, then any other.
let rank = |f: &FontFace| {
if f.family.contains("Symbol") {
return 0;
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(i) = SYSTEM_FALLBACKS.iter().position(|s| s.eq_ignore_ascii_case(&f.family)) {
return i + 1;
}
1000
};
let pick = |db: &FontDb| {
let faces = db.read_faces();
let mut v: Vec<&Arc<FontFace>> = faces.iter().filter(|f| !f.is_color() && crate::covers_cluster(f, cluster)).collect();
v.sort_by_key(|f| (rank(f), f.italic, (f.weight - 400.0).abs() as i32));
v.first().map(|f| (*f).clone())
};
#[cfg(not(target_arch = "wasm32"))]
{
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) {
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
self.load_cataloged(fam);
}
}
}
if let Some(f) = pick(self) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.system_plain_fallback(key) {
return pick(self);
}
let _ = key;
None
}
/// Load an installed font without colour tables covering `c`. True if one was loaded.
#[cfg(not(target_arch = "wasm32"))]
fn system_plain_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;
}
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).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() > 40 << 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
}
/// 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 {

View File

@ -141,10 +141,15 @@ mod tests {
#[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}🌈"] {
// As PowerPoint draws them: emoji-presentation characters (⭐ ✅ ☔ ⌚ ⏩) in colour even
// without a selector, text-presentation ones (♥ ✔ ☎ ⬆ ▶) as text unless U+FE0F asks, and
// keycaps always as text.
for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "🏳\u{FE0F}\u{200D}🌈", "⭐", "✅", "☔", "⌚", "⏩", "🀄"]
{
assert!(is_emoji_cluster(&c(yes)), "{yes:?}");
}
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→"] {
for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→", "♥", "✔", "☎", "⬆", "▶", "1\u{FE0F}\u{20E3}", "#\u{20E3}"]
{
assert!(!is_emoji_cluster(&c(no)), "{no:?}");
}
}

View File

@ -55,6 +55,10 @@ pub enum TextDir {
Vertical270,
Stacked,
EaVertical,
/// `mongolianVert`: turned like `vert`, lines running from left to right.
MongolianVertical,
/// `wordArtVertRtl`: stacked, lines running from right to left.
StackedRtl,
}
impl TextDir {
@ -65,14 +69,18 @@ impl TextDir {
TextDir::Vertical270 => "vert270",
TextDir::Stacked => "wordArtVert",
TextDir::EaVertical => "eaVert",
TextDir::MongolianVertical => "mongolianVert",
TextDir::StackedRtl => "wordArtVertRtl",
}
}
pub fn from_xml(s: &str) -> Option<Self> {
Some(match s {
"horz" => TextDir::Horizontal,
"vert" | "mongolianVert" => TextDir::Vertical,
"vert" => TextDir::Vertical,
"mongolianVert" => TextDir::MongolianVertical,
"vert270" => TextDir::Vertical270,
"wordArtVert" | "wordArtVertRtl" => TextDir::Stacked,
"wordArtVert" => TextDir::Stacked,
"wordArtVertRtl" => TextDir::StackedRtl,
"eaVert" => TextDir::EaVertical,
_ => return None,
})

View File

@ -616,5 +616,13 @@ mod tests {
assert_eq!(b.vert, Some(TextDir::Vertical270));
assert_eq!(b.columns, Some(2));
assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 }));
// mongolianVert is its own direction (PowerPoint runs its lines left to right) and is
// written back as it was read.
let d = parse(br#"<a:bodyPr vert="mongolianVert"/>"#).unwrap();
let b = body_pr(&d.root);
assert_eq!(b.vert, Some(TextDir::MongolianVertical));
assert_eq!(b.vert.map(|v| v.xml()), Some("mongolianVert"));
let d = parse(br#"<a:bodyPr vert="wordArtVertRtl"/>"#).unwrap();
assert_eq!(body_pr(&d.root).vert.map(|v| v.xml()), Some("wordArtVertRtl"));
}
}

View File

@ -1399,7 +1399,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
}
ctx.set_transform(m * Affine::translate(ps.offset));
ctx.set_paint(color(d.color, ps.opacity));
ctx.fill_rect(&d.rect);
fill_deco(ctx, d);
};
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);
@ -1426,7 +1426,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
// 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 {
let at = m * Affine::translate((*x, *y));
let at = m * glyph_place(run, *gid, *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;
@ -1447,6 +1447,37 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
}
}
/// Where glyph `gid` of `run` with its pen at (`x`, `y`) is drawn, in text space: there, turned a
/// quarter turn back about its em box's centre when it stands upright in vertical text.
pub(crate) fn glyph_place(run: &deckcraft_text::GlyphRun, gid: u32, x: f64, y: f64) -> Affine {
let at = Affine::translate((x, y));
match run.upright_centre(gid, x, y) {
Some((cx, cy)) => Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2) * Affine::translate((-cx, -cy)) * at,
None => at,
}
}
/// Fill an underline, strikethrough or highlight with the current paint and transform: its box,
/// its dashes, or its zigzag stroked.
pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
if let Some((pts, w)) = d.wave() {
let mut path = BezPath::new();
for (i, p) in pts.iter().enumerate() {
if i == 0 {
path.move_to(*p);
} else {
path.line_to(*p);
}
}
ctx.set_stroke(kurbo::Stroke::new(w.max(0.01)));
ctx.stroke_path(&path);
return;
}
for r in d.pieces() {
ctx.fill_rect(&r);
}
}
/// 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
@ -1497,7 +1528,7 @@ fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: T
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);
let at = pre_translate(m * glyph_place(run, *gid, *x, *y), offset * px) * skew * Affine::scale(k);
ctx.set_transform(at);
ctx.fill_path(&path);
if grow > 0.0 {

View File

@ -219,7 +219,7 @@ pub(crate) fn draw(
for d in &l.decos {
ctx.set_transform(place);
ctx.set_paint(color(d.color, alpha));
ctx.fill_rect(&d.rect);
crate::fill_deco(ctx, d);
}
}
}

View File

@ -1644,3 +1644,65 @@ fn a_fill_overlay_blends_over_the_fill() {
let (over, _) = draw([200, 100, 50], "over");
assert!((over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3, "{over:?}");
}
#[test]
fn dotted_and_wavy_underlines_draw_their_pattern() {
// Underlined spaces only, so the underline is the only ink: a red line under " ".
let row_runs = |u: &str| {
let mut sh = Shape { id: ShapeId(760), xfrm: Some(Xfrm::new(0.0, 0.0, 900.0, 300.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text(" ");
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(96.0);
props.underline = Some(u.into());
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let red = |x: u32, y: u32| {
let px = img.pixel(x, y);
px[0] > 150 && px[1] < 120
};
// The ink's rows, and the number of separate red stretches along the most inked row.
let rows: Vec<u32> = (0..300).filter(|&y| (0..900).any(|x| red(x, y))).collect();
let best = rows.iter().copied().max_by_key(|&y| (0..900).filter(|&x| red(x, y)).count()).unwrap_or(0);
let stretches = (1..900).filter(|&x| red(x, best) && !red(x - 1, best)).count();
(rows.len(), stretches)
};
let (sng_rows, sng_runs) = row_runs("sng");
assert_eq!(sng_runs, 1, "a single line is one stretch");
let (_, dotted_runs) = row_runs("dotted");
assert!(dotted_runs > 10, "dots: {dotted_runs}");
let (wavy_rows, _) = row_runs("wavy");
assert!(wavy_rows > sng_rows, "a wave spans more rows ({wavy_rows}) than a single line ({sng_rows})");
}
#[test]
fn east_asian_vertical_text_stands_ideographs_upright() {
// "一" is a horizontal stroke. In eaVert text it stays horizontal (upright) while the line runs
// down the shape; in vert it lies on its side like Latin text.
let ink = |dir: deckcraft_model::text::TextDir| {
let mut sh = Shape { id: ShapeId(770), xfrm: Some(Xfrm::new(100.0, 50.0, 200.0, 400.0)), text_box: true, ..Default::default() };
let mut t = TextBody::from_text("一");
t.body.vert = Some(dir);
let props = &mut t.paragraphs[0].runs[0].props;
props.size = Some(80.0);
props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0))));
sh.text = Some(t);
let p = deck_with(vec![sh]);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
for y in 0..540 {
for x in 0..960 {
if img.pixel(x, y)[0] < 128 {
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
}
assert!(x1 >= x0 && y1 >= y0, "no ink for {dir:?}");
(x1 - x0, y1 - y0)
};
let (w, h) = ink(deckcraft_model::text::TextDir::EaVertical);
assert!(w > h * 3, "eaVert: upright stroke {w}x{h}");
let (w, h) = ink(deckcraft_model::text::TextDir::Vertical);
assert!(h > w * 3, "vert: stroke on its side {w}x{h}");
}

View File

@ -140,10 +140,12 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
}
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 r in d.pieces() {
let path = warp_path(&r.to_path(0.1), tol, seg, &|p| mapper.map(li, p));
ctx.fill_path(&path);
}
};
for d in l.decos.iter().filter(|d| d.behind) {
deco(ctx, d);

View File

@ -6,7 +6,7 @@
//! [`visual_order`] rule L2). Rule L4 (mirrored brackets) is applied by the shaper, which draws
//! mirrorable characters in right-to-left items with their mirrored glyphs.
use unicode_bidi::BidiClass::{self, AL, AN, B, BN, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO, S, WS};
use unicode_bidi::BidiClass::{self, AL, AN, B, BN, CS, EN, ES, ET, FSI, L, LRE, LRI, LRO, NSM, ON, PDF, PDI, R, RLE, RLI, RLO, S, WS};
use unicode_bidi::{BidiInfo, Level, bidi_class};
/// Does anything in the text need the full algorithm? (Strong right-to-left, Arabic numbers or
@ -27,6 +27,149 @@ pub(crate) fn levels(text: &str, chars: &[char], rtl: bool) -> Vec<u8> {
text.char_indices().map(|(b, _)| info.levels.get(b).map(|l| l.number()).unwrap_or(base)).collect()
}
/// Is `lang` (BCP 47) a language written right to left? Its runs resolve neutral characters and
/// digits the right-to-left way (see [`levels_lang`]).
pub(crate) fn is_rtl_lang(lang: &str) -> bool {
let primary = lang.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
matches!(primary.as_str(), "ar" | "he" | "iw" | "fa" | "ur" | "yi" | "ji" | "ps" | "sd" | "ug" | "dv" | "syr" | "ckb" | "arc")
}
/// How a character resolves before levels are assigned.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Dir {
L,
R,
/// A number inside right-to-left text: kept left to right, placed with the right-to-left text.
Num,
/// Not resolved yet (neutral).
Open,
}
/// Embedding levels as PowerPoint resolves them: the language of each character's run
/// (`rtl_lang`, from `a:rPr/@lang`) decides how neutral characters and digits go, where UAX #9
/// would look at the surrounding text only. Observed on desktop PowerPoint:
/// - in a run of a left-to-right language (or none), digits and punctuation are left to right,
/// and a space takes the direction of the character before it (so the space after a Hebrew word
/// travels with that word);
/// - in a run of a right-to-left language (Hebrew, Arabic…), punctuation and spaces between text
/// of one direction take it, everywhere else they are right to left; digits are a number in
/// right-to-left text unless left-to-right text precedes them.
///
/// Paragraphs with explicit embeddings or isolates keep the plain algorithm ([`levels`]).
pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[bool]) -> Vec<u8> {
let base = u8::from(rtl);
let any_rtl_lang = rtl_lang.iter().any(|r| *r);
if !rtl && !any_rtl_lang && !needs_bidi(chars) {
return vec![0; chars.len()];
}
let classes: Vec<BidiClass> = chars.iter().map(|c| bidi_class(*c)).collect();
if classes.iter().any(|c| matches!(c, LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI)) {
return levels(text, chars, rtl);
}
let n = chars.len();
let rl = |i: usize| rtl_lang.get(i).copied().unwrap_or(false);
let sos = if rtl { Dir::R } else { Dir::L };
// W1: combining marks take the class of the character they sit on.
let mut cls = classes;
for i in 0..n {
if cls.get(i) == Some(&NSM) {
let prev = if i == 0 { if rtl { R } else { L } } else { cls.get(i - 1).copied().unwrap_or(L) };
if let Some(c) = cls.get_mut(i) {
*c = prev;
}
}
}
let mut dir = vec![Dir::Open; n];
// Strong letters and digits.
let mut last_strong: Option<BidiClass> = None;
for i in 0..n {
let c = cls.get(i).copied().unwrap_or(ON);
let d = match c {
L => Dir::L,
R | AL => Dir::R,
AN => Dir::Num,
EN if !rl(i) => Dir::L,
// W7: European digits after left-to-right text are left to right.
EN if last_strong == Some(L) => Dir::L,
EN => Dir::Num,
_ => Dir::Open,
};
if matches!(c, L | R | AL) {
last_strong = Some(c);
}
if let Some(x) = dir.get_mut(i) {
*x = d;
}
}
// W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next
// to them, belong to the number.
for i in 0..n {
if !rl(i) || dir.get(i) != Some(&Dir::Open) {
continue;
}
let c = cls.get(i).copied().unwrap_or(ON);
let num = |j: Option<usize>| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num);
let joined = match c {
ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)),
ET => num(i.checked_sub(1)) || (i + 1..n).find(|&j| cls.get(j) != Some(&ET)).is_some_and(|j| num(Some(j))),
_ => false,
};
if joined && let Some(x) = dir.get_mut(i) {
*x = Dir::Num;
}
}
// Punctuation in left-to-right runs is left to right.
for i in 0..n {
if !rl(i)
&& dir.get(i) == Some(&Dir::Open)
&& !matches!(cls.get(i), Some(WS | S))
&& let Some(x) = dir.get_mut(i)
{
*x = Dir::L;
}
}
// Neutrals in right-to-left runs: the direction around them when both sides agree, else
// right to left. Unresolved spaces of left-to-right runs are looked through.
let strong_at = |dir: &[Dir], j: usize| match dir.get(j) {
Some(Dir::L) => Some(Dir::L),
Some(Dir::R | Dir::Num) => Some(Dir::R),
_ => None,
};
let mut i = 0;
while i < n {
if !(rl(i) && dir.get(i) == Some(&Dir::Open)) {
i += 1;
continue;
}
let start = i;
while i < n && rl(i) && dir.get(i) == Some(&Dir::Open) {
i += 1;
}
let before = (0..start).rev().find_map(|j| strong_at(&dir, j)).unwrap_or(sos);
let after = (i..n).find_map(|j| strong_at(&dir, j)).unwrap_or(sos);
let d = if before == after { before } else { Dir::R };
for x in dir.get_mut(start..i).into_iter().flatten() {
*x = d;
}
}
// Spaces in left-to-right runs follow the character before them.
let mut prev = Dir::L;
for d in dir.iter_mut() {
if *d == Dir::Open {
*d = prev;
}
prev = if *d == Dir::Num { Dir::R } else { *d };
}
dir.iter()
.map(|d| match (d, base % 2 == 1) {
(Dir::L, false) | (Dir::R, true) => base,
(Dir::L, true) | (Dir::R, false) | (Dir::Open, _) => base + 1,
(Dir::Num, false) => base + 2,
(Dir::Num, true) => base + 1,
})
.collect()
}
/// Whitespace-like for rule L1: reset to the paragraph level when trailing a line or preceding a
/// segment/paragraph separator.
fn l1_whitespace(c: BidiClass) -> bool {
@ -131,6 +274,40 @@ mod tests {
assert_eq!(lv("ab", true), vec![2, 2]);
}
/// Visual string of `s` laid out on one line with every character in a run of language
/// direction `rtl_lang`, as PowerPoint orders it.
fn vis_lang(s: &str, rtl: bool, rtl_lang: bool) -> String {
let chars: Vec<char> = s.chars().collect();
let l = levels_lang(s, &chars, rtl, &vec![rtl_lang; chars.len()]);
visual_order(&line_levels(&chars, &l, u8::from(rtl))).iter().map(|&i| chars[i]).collect()
}
#[test]
fn run_language_decides_neutrals_like_powerpoint() {
// Observed in PowerPoint with each space highlighted. An English run: the space after a
// Hebrew word goes with it, punctuation is left to right.
assert_eq!(vis_lang("x אב גד y", false, false), "x דג באy");
assert_eq!(vis_lang("אב גד x y", false, false), " דג באx y");
assert_eq!(vis_lang("x אב, גד! y", false, false), "x בא, דג! y");
// Digits after Hebrew stay left to right in an English run.
assert_eq!(vis_lang("אב 12", false, false), " בא12");
// A Hebrew run: neutrals between different directions are right to left, and digits are
// a number inside the right-to-left text.
assert_eq!(vis_lang("אב גד x y", false, true), " דג באx y");
assert_eq!(vis_lang("12 אב 34 x", false, true), " 34 בא 12x");
assert_eq!(vis_lang("x אב, גד! y", false, true), "x !דג ,בא y");
// Between two left-to-right words a Hebrew run's space stays left to right.
assert_eq!(vis_lang("Version 2.0 ok", false, true), "Version 2.0 ok");
// An English sentence in a right-to-left paragraph keeps its full stop at its end.
assert_eq!(vis_lang("Hello world.", true, false), "Hello world.");
// Right-to-left runs keep W4: "45.6%" is one number.
assert_eq!(vis_lang("אב 45.6% גד", false, true), "דג 45.6% בא");
// Without right-to-left text or runs, the fast path.
let chars: Vec<char> = "plain, text".chars().collect();
assert_eq!(levels_lang("plain, text", &chars, false, &[false; 11]), vec![0; 11]);
assert!(is_rtl_lang("he-IL") && is_rtl_lang("ar") && is_rtl_lang("FA-ir") && !is_rtl_lang("en-US") && !is_rtl_lang(""));
}
#[test]
fn l1_resets_trailing_whitespace_and_tabs() {
let chars: Vec<char> = "ab\tcd ".chars().collect();

View File

@ -54,11 +54,31 @@ pub struct GlyphRun {
pub para: usize,
pub chars: (usize, usize),
pub link: bool,
/// Alpha of the fill (0–1).
/// Opacity of the fill on top of `color`'s own alpha (0 for no fill).
pub alpha: f64,
pub gradient: Option<deckcraft_model::style::Gradient>,
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
pub effects: Option<RunEffects>,
/// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn
/// back about the centre of its em box ([`GlyphRun::upright_centre`]) inside text that is
/// laid out turned a quarter turn.
pub upright: bool,
}
/// Share of the size the ideographic em box's centre sits above the baseline (CJK fonts put the
/// em box from 0.88 em above to 0.12 em below it).
pub const UPRIGHT_CENTRE: f64 = 0.38;
impl GlyphRun {
/// The point an upright glyph at pen position (`x`, `y`) turns about: the centre of its em box,
/// `advance` wide (`None` when the run is not upright).
pub fn upright_centre(&self, gid: u32, x: f64, y: f64) -> Option<(f64, f64)> {
if !self.upright {
return None;
}
let adv = self.face.advance(gid) * self.size / self.face.upem.max(1.0);
Some((x + adv / 2.0, y - self.size * UPRIGHT_CENTRE))
}
}
/// A text run's shadow and glow, colours resolved.
@ -93,6 +113,150 @@ pub struct Deco {
pub behind: bool,
/// Paragraph the decoration belongs to (by-paragraph animation).
pub para: usize,
/// How the line fills `rect`.
pub line: DecoLine,
}
/// How an underline fills its box.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum DecoLine {
/// The whole box.
#[default]
Solid,
/// Dashes or dots across the box's height, from its left end.
Dashed(Dash),
/// A zigzag between the box's top and bottom: `stroke` wide, one wave every `period`.
Wave { stroke: f64, period: f64 },
}
/// Dash patterns of the dotted and dashed underlines, in units of the line's thickness. Measured
/// on PowerPoint's export: dots and their gaps as long as the line is thick, dashes three
/// thicknesses long with gaps of two.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dash {
Dot,
Dash,
Long,
DotDash,
DotDotDash,
}
impl Dash {
/// On, off, on, off… lengths in thicknesses.
fn pattern(self) -> &'static [f64] {
match self {
Dash::Dot => &[1.0, 1.0],
Dash::Dash => &[3.0, 2.0],
Dash::Long => &[6.0, 2.0],
Dash::DotDash => &[3.0, 2.0, 1.0, 2.0],
Dash::DotDotDash => &[3.0, 2.0, 1.0, 2.0, 1.0, 2.0],
}
}
}
impl Deco {
/// The filled boxes of a solid or dashed line (dashes start at the box's left end). A wavy
/// line gives a thin box along its middle, for drawing that can't stroke a path.
pub fn pieces(&self) -> Vec<Rect> {
let r = self.rect;
match self.line {
DecoLine::Solid => vec![r],
DecoLine::Wave { stroke, .. } => {
let c = (r.y0 + r.y1) / 2.0;
vec![Rect::new(r.x0, c - stroke / 2.0, r.x1, c + stroke / 2.0)]
}
DecoLine::Dashed(d) => {
let unit = r.height().max(0.1);
let pat = d.pattern();
let mut out = Vec::new();
let mut x = r.x0;
let mut k = 0usize;
while x < r.x1 && out.len() < 10_000 {
let len = pat.get(k % pat.len()).copied().unwrap_or(1.0) * unit;
if k.is_multiple_of(2) {
out.push(Rect::new(x, r.y0, (x + len).min(r.x1), r.y1));
}
x += len;
k += 1;
}
out
}
}
}
/// The zigzag of a wavy underline, left to right from the box's top, and its stroke width;
/// None for other lines.
pub fn wave(&self) -> Option<(Vec<Point>, f64)> {
let DecoLine::Wave { stroke, period } = self.line else { return None };
let r = self.rect;
let half = (period / 2.0).max(0.1);
let n = ((r.width().max(0.0) / half).ceil() as usize).min(20_000);
let pts = (0..=n)
.map(|i| {
let x = (r.x0 + i as f64 * half).min(r.x1);
Point::new(x, if i % 2 == 0 { r.y0 } else { r.y1 })
})
.collect();
Some((pts, stroke))
}
}
/// An underline style (`a:rPr/@u`).
#[derive(Clone, Copy, Debug, PartialEq)]
enum Underline {
Single,
Double,
Heavy,
/// Dotted or dashed, heavy or not.
Dashed(Dash, bool),
/// Wavy, heavy or not.
Wavy(bool),
}
impl Underline {
fn from_xml(u: &str) -> Option<Self> {
Some(match u {
"none" => return None,
// PowerPoint underlines the spaces of a "words" underline too.
"words" => Underline::Single,
// At text sizes PowerPoint's double wave reads as two straight lines.
"dbl" | "wavyDbl" => Underline::Double,
"heavy" => Underline::Heavy,
"dotted" => Underline::Dashed(Dash::Dot, false),
"dottedHeavy" => Underline::Dashed(Dash::Dot, true),
"dash" => Underline::Dashed(Dash::Dash, false),
"dashHeavy" => Underline::Dashed(Dash::Dash, true),
"dashLong" => Underline::Dashed(Dash::Long, false),
"dashLongHeavy" => Underline::Dashed(Dash::Long, true),
"dotDash" => Underline::Dashed(Dash::DotDash, false),
"dotDashHeavy" => Underline::Dashed(Dash::DotDash, true),
"dotDotDash" => Underline::Dashed(Dash::DotDotDash, false),
"dotDotDashHeavy" => Underline::Dashed(Dash::DotDotDash, true),
"wavy" => Underline::Wavy(false),
"wavyHeavy" => Underline::Wavy(true),
_ => Underline::Single,
})
}
/// The boxes of this underline below the baseline (top, bottom, fill), from the font's
/// underline position `p` and thickness `t` at the run's size. Measured on PowerPoint's export
/// (Arial 96 pt: p = 10.2, t = 7.0): single at p..p+t; heavy from p-t to p+0.55t; double as two
/// lines 0.55t thick spanning p-t..p+t; wavy a thin zigzag from p-0.6t to p+1.1t, a wave every
/// 3.9t.
fn boxes(self, p: f64, t: f64) -> Vec<(f64, f64, DecoLine)> {
let heavy = (p - t, p + 0.55 * t);
match self {
Underline::Single => vec![(p, p + t, DecoLine::Solid)],
Underline::Heavy => vec![(heavy.0, heavy.1, DecoLine::Solid)],
Underline::Double => vec![(p - t, p - 0.45 * t, DecoLine::Solid), (p + 0.45 * t, p + t, DecoLine::Solid)],
Underline::Dashed(d, false) => vec![(p, p + t, DecoLine::Dashed(d))],
Underline::Dashed(d, true) => vec![(heavy.0, heavy.1, DecoLine::Dashed(d))],
Underline::Wavy(h) => {
let stroke = (t * 0.15).max(0.4) * if h { 2.0 } else { 1.0 };
vec![(p - 0.6 * t, p + 1.1 * t, DecoLine::Wave { stroke, period: t * 3.9 })]
}
}
}
}
#[derive(Clone, Debug, Default)]
@ -187,7 +351,7 @@ struct Style {
baseline: f64,
spacing: f64,
caps: Caps,
underline: Option<Rgba>,
underline: Option<(Rgba, Underline)>,
strike: Option<Strike>,
highlight: Option<Rgba>,
outline: Option<(Rgba, f64)>,
@ -227,6 +391,8 @@ struct Cell {
head: usize,
/// A grapheme starts here: carets stand and lines break only before such cells.
boundary: bool,
/// Drawn upright in East Asian vertical text.
upright: bool,
}
impl Cell {
@ -269,7 +435,8 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
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),
// The resolved colour carries the fill's transparency (`a:alpha`): it is applied once.
Some(Fill::Solid { color }) => (ctx.color(color, None), 1.0, None),
Some(Fill::Gradient(g)) => {
// Colours resolved here (theme, colour map), so the renderer needs no colour scheme.
let mut g = g.clone();
@ -292,7 +459,8 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
};
let ea = slot_family(&r.font_ea, if major { "+mj-ea" } else { "+mn-ea" });
let cs = slot_family(&r.font_cs, if major { "+mj-cs" } else { "+mn-cs" });
let underline = r.underline.as_deref().filter(|u| *u != "none").map(|_| r.underline_color.as_ref().map(|c| ctx.color(c, None)).unwrap_or(color));
let underline =
r.underline.as_deref().and_then(Underline::from_xml).map(|u| (r.underline_color.as_ref().map(|c| ctx.color(c, None)).unwrap_or(color), u));
let outline = r.outline.as_ref().and_then(|l| match &l.fill {
Some(Fill::Solid { color: c }) => Some((ctx.color(c, None), l.width.unwrap_or(0.75))),
_ => None,
@ -404,37 +572,129 @@ fn alpha_num(n: u32, upper: bool) -> String {
if upper { s.to_uppercase() } else { s }
}
/// Text of an auto-number bullet: `arabicPeriod` 3 → "3.".
/// Letters of a numbering alphabet: 1 → the first, past the end the letter doubles ("aa"), as
/// the Latin alphabetic schemes do.
fn letters(list: &[char], n: u32) -> String {
let len = list.len().max(1) as u32;
let Some(i) = n.checked_sub(1) else { return String::new() };
let c = list.get((i % len) as usize).copied().unwrap_or('?');
std::iter::repeat_n(c, (i / len + 1).min(50) as usize).collect()
}
/// `n` written with the digits `zero..zero+9` (Thai, Devanagari, full-width digits).
fn digits_in(n: u32, zero: char) -> String {
n.to_string().chars().map(|d| d.to_digit(10).and_then(|v| char::from_u32(zero as u32 + v)).unwrap_or(d)).collect()
}
const CJK_DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
/// Chinese numerals: 10 → 十, 11 → 十一, 25 → 二十五, 105 → 一百零五.
fn chinese_num(n: u32) -> String {
if n == 0 || n > 99_999 {
return n.to_string();
}
let units = ['\0', '十', '百', '千', '万'];
let ds: Vec<u32> = n.to_string().chars().filter_map(|c| c.to_digit(10)).collect();
let mut out = String::new();
let mut zero = false;
for (i, d) in ds.iter().enumerate() {
let place = ds.len() - 1 - i;
if *d == 0 {
zero = true;
continue;
}
if zero {
out.push('零');
zero = false;
}
// "十" alone for 10–19, not "一十".
if !(*d == 1 && place == 1 && i == 0) {
out.push(CJK_DIGITS.get(*d as usize).copied().unwrap_or('?'));
}
if let Some(u) = units.get(place).filter(|u| **u != '\0') {
out.push(*u);
}
}
out
}
/// Text of an auto-number bullet: `arabicPeriod` 3 → "3.". The East Asian, Thai, Hindi, Arabic
/// and Hebrew schemes are written as PowerPoint draws them: `ea1ChsPeriod` 10 → "十.",
/// `ea1JpnKorPeriod` 10 → "一〇." (digit by digit), `arabicDbPeriod` 13 → "13.",
/// `thaiAlphaParenR` 4 → "ง)", `hindiAlpha1Period` 1 → "क.", `arabic2Minus` 10 → "ي-"
/// (abjad order), `circleNumWdWhitePlain` 13 → "③" (those circles go to ten).
pub fn autonum(scheme: &str, n: u32) -> String {
let (core, upper) = if scheme.starts_with("romanUc") {
(roman(n, true), true)
const THAI: [char; 41] = [
'ก', 'ข', 'ค', 'ง', 'จ', 'ฉ', 'ช', 'ซ', 'ฌ', 'ญ', 'ฎ', 'ฏ', 'ฐ', 'ฑ', 'ฒ', 'ณ', 'ด', 'ต', 'ถ', 'ท', 'ธ', 'น', 'บ', 'ป', 'ผ', 'ฝ', 'พ', 'ฟ',
'ภ', 'ม', 'ย', 'ร', 'ล', 'ว', 'ศ', 'ษ', 'ส', 'ห', 'ฬ', 'อ', 'ฮ',
];
const HINDI_VOWELS: [char; 11] = ['अ', 'आ', 'इ', 'ई', 'उ', 'ऊ', 'ऋ', 'ए', 'ऐ', 'ओ', 'औ'];
const HINDI_CONSONANTS: [char; 33] = [
'क', 'ख', 'ग', 'घ', 'ङ', 'च', 'छ', 'ज', 'झ', 'ञ', 'ट', 'ठ', 'ड', 'ढ', 'ण', 'त', 'थ', 'द', 'ध', 'न', 'प', 'फ', 'ब', 'भ', 'म', 'य', 'र', 'ल',
'व', 'श', 'ष', 'स', 'ह',
];
const ARABIC_ALPHABET: [char; 28] =
['ا', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي'];
const ARABIC_ABJAD: [char; 28] =
['ا', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ'];
const HEBREW: [char; 22] = ['א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ז', 'ח', 'ט', 'י', 'כ', 'ל', 'מ', 'נ', 'ס', 'ע', 'פ', 'צ', 'ק', 'ר', 'ש', 'ת'];
let circled = |first: u32, count: u32| char::from_u32(first + n.saturating_sub(1) % count).map(String::from).unwrap_or_else(|| n.to_string());
let core = if scheme.starts_with("romanUc") {
roman(n, true)
} else if scheme.starts_with("romanLc") {
(roman(n, false), false)
roman(n, false)
} else if scheme.starts_with("alphaUc") {
(alpha_num(n, true), true)
alpha_num(n, true)
} else if scheme.starts_with("alphaLc") {
(alpha_num(n, false), false)
alpha_num(n, false)
} else if scheme.starts_with("circleNumWdWhite") {
return circled(0x2460, 10);
} else if scheme.starts_with("circleNumWdBlack") {
return circled(0x2776, 10);
} else if scheme.starts_with("circleNum") {
let c = char::from_u32(0x2460 + n.saturating_sub(1).min(19)).unwrap_or('•');
return c.to_string();
return char::from_u32(0x2460 + n.saturating_sub(1).min(19)).map(String::from).unwrap_or_else(|| n.to_string());
} else if scheme.starts_with("ea1Chs") || scheme.starts_with("ea1Cht") {
chinese_num(n)
} else if scheme.starts_with("ea1Jpn") {
n.to_string().chars().map(|d| d.to_digit(10).and_then(|v| CJK_DIGITS.get(v as usize).copied()).unwrap_or(d)).collect()
} else if scheme.starts_with("arabicDb") {
digits_in(n, '0')
} else if scheme.starts_with("thaiAlpha") {
letters(&THAI, n)
} else if scheme.starts_with("thaiNum") {
digits_in(n, '๐')
} else if scheme.starts_with("hindiAlpha1") {
letters(&HINDI_CONSONANTS, n)
} else if scheme.starts_with("hindiAlpha") {
letters(&HINDI_VOWELS, n)
} else if scheme.starts_with("hindiNum") {
digits_in(n, '०')
} else if scheme.starts_with("arabic1") {
letters(&ARABIC_ALPHABET, n)
} else if scheme.starts_with("arabic2") {
letters(&ARABIC_ABJAD, n)
} else if scheme.starts_with("hebrew2") {
letters(&HEBREW, n)
} else {
(n.to_string(), false)
n.to_string()
};
let _ = upper;
// East Asian schemes end with a full-width full stop.
let wide = scheme.starts_with("ea1") || scheme.starts_with("arabicDb");
if scheme.ends_with("ParenBoth") {
format!("({core})")
} else if scheme.ends_with("ParenR") {
format!("{core})")
} else if scheme.ends_with("Period") {
format!("{core}.")
format!("{core}{}", if wide { '.' } else { '.' })
} else if scheme.ends_with("Minus") {
format!("- {core} -")
format!("{core}-")
} else {
core
}
}
fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option<u32>) -> ParaShaped {
#[allow(clippy::too_many_arguments)]
fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option<u32>, vertical: Upright) -> ParaShaped {
let db = FontDb::global();
let Some(para) = body.paragraphs.get(pi) else {
let face = db.face("Inter", "Regular");
@ -471,7 +731,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
char_style.extend(std::iter::repeat_n(si, t.chars().count()));
text.push_str(&t);
}
let cells = shape_cells(ctx, &text, &char_style, &styles, &mut faces, rtl);
let cells = shape_cells(ctx, &text, &char_style, &styles, &mut faces, rtl, vertical);
let lead = para.runs.first().map(|r| resolve::run(ctx, shape, para, &r.props)).unwrap_or_else(|| resolve::run(ctx, shape, para, &para.end_props));
let lead_style = make_style(ctx, &lead, scale, opts.prompt_color);
let lead_size = lead_style.size;
@ -552,7 +812,16 @@ fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option<RunEffects> {
(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> {
#[allow(clippy::too_many_arguments)]
fn shape_cells(
ctx: &Ctx,
text: &str,
char_style: &[usize],
styles: &[Style],
faces: &mut Vec<Arc<FontFace>>,
rtl: bool,
vertical: Upright,
) -> Vec<Cell> {
let db = FontDb::global();
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
@ -560,7 +829,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
let mut byte_of: Vec<usize> = text.char_indices().map(|(b, _)| b).collect();
byte_of.push(text.len());
let char_at_byte: HashMap<usize, usize> = byte_of.iter().enumerate().map(|(i, b)| (*b, i)).collect();
let levels = bidi::levels(text, &chars, rtl);
let style_rtl: Vec<bool> = styles.iter().map(|st| st.lang.as_deref().is_some_and(bidi::is_rtl_lang)).collect();
let rtl_lang: Vec<bool> = char_style.iter().map(|&si| style_rtl.get(si).copied().unwrap_or(false)).collect();
let levels = bidi::levels_lang(text, &chars, rtl, &rtl_lang);
let scripts = script::resolve(&chars);
let mut boundary = vec![false; n];
let mut graphemes: Vec<(usize, usize)> = Vec::new();
@ -602,7 +873,11 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
.filter(|f| deckcraft_fonts::covers_cluster(f, cluster));
let face = match (sym_face, prev.as_ref()) {
(Some(f), _) => f,
(None, Some(p)) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
(None, Some(p))
if neutral && !p.is_color() && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) =>
{
p.clone()
}
(None, _) => {
let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc));
db.cascade(cluster, chain, st.style_name)
@ -624,25 +899,118 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
adv: 0.0,
head: i,
boundary: boundary.get(i).copied().unwrap_or(true),
upright: false,
})
.collect();
// In East Asian vertical text, ideographs, kana, Hangul, full-width forms and the like stand
// upright (a whole grapheme at a time); other text lies on its side.
if vertical != Upright::None {
for &(a, b) in &graphemes {
let up = chars.get(a).is_some_and(|c| if vertical == Upright::All { !c.is_whitespace() } else { is_upright(*c) });
for c in cells.get_mut(a..b).into_iter().flatten() {
c.upright = up;
}
}
}
// 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 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 up = |i: usize| cells.get(i).is_some_and(|c| c.upright);
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), up(i));
let mut items: Vec<(usize, usize)> = Vec::new();
let mut a = 0;
while a < n {
let mut b = a + 1;
while b < n && key(b) == key(a) {
b += 1;
}
shape_item(&mut cells, styles, faces, text, &byte_of, &char_at_byte, a..b, scripts.get(a).and_then(|s| s.0), lang(a), upper(a));
items.push((a, b));
a = b;
}
// Upright glyphs keep their horizontal forms: PowerPoint turns "、", "。" and "「" as they are
// (no `vert` alternates).
for (a, b) in items {
shape_item(&mut cells, styles, faces, text, &byte_of, &char_at_byte, a..b, scripts.get(a).and_then(|s| s.0), lang(a), upper(a));
}
// Stacked text: each character takes one em down the line, centred across it (PowerPoint
// stacks 18 pt Latin letters about 18 pt apart, a space only its own width).
if vertical == Upright::All {
for i in 0..n {
let Some(c) = cells.get(i) else { continue };
if !c.upright || c.head != i || c.glyphs.is_empty() {
continue;
}
let size = styles.get(c.style).map(|st| st.size).unwrap_or(18.0);
let width: f64 = (i..n).take_while(|&j| cells.get(j).is_some_and(|d| d.head == i)).filter_map(|j| cells.get(j)).map(|d| d.adv).sum();
let pad = (size - width) / 2.0;
if let Some(c) = cells.get_mut(i) {
for g in &mut c.glyphs {
g.1 += pad;
}
}
for j in i..n {
match cells.get_mut(j) {
Some(d) if d.head == i => d.adv = if j == i { size } else { 0.0 },
_ => break,
}
}
}
}
cells
}
/// A character in capitals (`cap="all"`/`"small"`): its uppercase when that is one character.
/// PowerPoint leaves characters whose capital is a sequence as they are: "ß" stays "ß" (not "SS"),
/// "fi" and "ʼn" stay.
fn caps_char(ch: char) -> char {
let mut up = ch.to_uppercase();
match (up.next(), up.next()) {
(Some(u), None) => u,
_ => ch,
}
}
/// Does `c` stand upright in East Asian vertical text? Ideographs, kana, Hangul, Bopomofo, CJK
/// symbols and punctuation, full-width forms, enclosed and compatibility CJK, circled numbers
/// and emoji (Unicode's Vertical_Orientation U and T, roughly); Latin, digits and the rest of the
/// half-width text lie on their side as in `vert`.
fn is_upright(c: char) -> bool {
matches!(c as u32,
0x1100..=0x11FF
| 0x2460..=0x24FF
| 0x2E80..=0x2FFF
| 0x3000..=0x303F
| 0x3040..=0x30FF
| 0x3100..=0x312F
| 0x3130..=0x318F
| 0x3190..=0x31FF
| 0x3200..=0x33FF
| 0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xA960..=0xA97F
| 0xAC00..=0xD7FF
| 0xF900..=0xFAFF
| 0xFE10..=0xFE1F
| 0xFE30..=0xFE6F
| 0xFF01..=0xFF60
| 0xFFE0..=0xFFE6
| 0x1F000..=0x1FAFF
| 0x20000..=0x3FFFF
)
}
/// Which characters stand upright in vertical text.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Upright {
/// None (horizontal text, vert, vert270, mongolianVert).
None,
/// Ideographs, kana and the like (eaVert).
Cjk,
/// Every character, one under the other (wordArtVert).
All,
}
/// Size of superscript and subscript text relative to the run's size.
const SUB_SUPER: f64 = 0.66;
@ -686,7 +1054,7 @@ fn shape_item(
let (Some(&b0), Some(&b1)) = (byte_of.get(range.start), byte_of.get(range.end)) else { return };
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 glyphs = deckcraft_fonts::shape_range(face, text, b0..b1, &params, |ch| if upper { caps_char(ch) } else { ch });
let upem = face.upem.max(1.0);
let scale_of = |cell: &Cell| {
let st = styles.get(cell.style);
@ -896,31 +1264,50 @@ 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`).
/// East Asian commas and full stops, the punctuation PowerPoint lets hang past the end of a line
/// (`hangingPunct`). Closing brackets don't hang: "「。」" overflowing by its "」" moves to the next
/// line whole.
fn is_hanging_punct(c: char) -> bool {
matches!(c, '、' | '。' | ',' | '.' | '、' | '。')
}
/// Small kana, the prolonged sound mark and iteration marks: PowerPoint keeps them off the start
/// of a line (strict Japanese line breaking), where UAX #14's default allows a break before them.
fn is_cj_nonstarter(c: char) -> bool {
matches!(
c,
'、' | '。'
| ','
| '.'
| ':'
| ';'
| '!'
| '?'
| ')'
| '」'
| '』'
| '】'
| '〕'
| '〉'
| '》'
| '〙'
| '〛'
| '}'
| ']'
| '」'
| '、'
| '。'
'ぁ' | 'ぃ'
| 'ぅ'
| 'ぇ'
| 'ぉ'
| 'っ'
| 'ゃ'
| 'ゅ'
| 'ょ'
| 'ゎ'
| 'ゕ'
| 'ゖ'
| 'ァ'
| 'ィ'
| 'ゥ'
| 'ェ'
| 'ォ'
| 'ッ'
| 'ャ'
| 'ュ'
| 'ョ'
| 'ヮ'
| 'ヵ'
| 'ヶ'
| 'ー'
| '々'
| '〻'
| 'ゝ'
| 'ゞ'
| 'ヽ'
| 'ヾ'
| '\u{31F0}'..='\u{31FF}'
| 'ァ'..='ー'
)
}
@ -951,7 +1338,12 @@ fn break_opportunities(text: &str, latin_break: bool) -> (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() };
// PowerPoint never breaks after a zero-width space, nor before an em dash (only after it).
let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' || is_cj_nonstarter(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)
@ -974,7 +1366,7 @@ fn break_lines(
first_w: f64,
rest_w: f64,
wrap: bool,
tab_stops: &[f64],
tab_stops: &[Tab],
default_tab: f64,
x0_first: f64,
x0_rest: f64,
@ -1003,7 +1395,11 @@ fn break_lines(
next = i + 1;
break;
}
let adv = if c == '\t' { tab_advance(x0 + x, tab_stops, default_tab) } else { p.cells.get(i).map(|c| c.adv).unwrap_or(0.0) };
let adv = if c == '\t' {
tab_advance(x0 + x, tab_stops, default_tab, tab_segment(&p.cells, i))
} else {
p.cells.get(i).map(|c| c.adv).unwrap_or(0.0)
};
let breakable = |j: usize| p.cells.get(j).is_none_or(|c| c.breakable(j));
if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) {
last_break = Some(i);
@ -1015,6 +1411,15 @@ fn break_lines(
next = i + 1;
break;
}
// A tab that would run past the end moves to the next line (and to its first stop there).
if wrap && c == '\t' && x + adv > avail + 0.01 && i > start {
end = i;
next = i;
while end > start && p.cells.get(end - 1).is_some_and(|c| c.ch == ' ') {
end -= 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
@ -1056,10 +1461,72 @@ fn break_lines(
lines
}
fn tab_advance(x: f64, stops: &[f64], default_tab: f64) -> f64 {
/// How a tab stop aligns the text after it (`a:tab/@algn`).
#[derive(Clone, Copy, Debug, PartialEq)]
enum TabAlign {
Left,
Center,
Right,
/// The first "." of the text sits at the stop (text without one ends there).
Decimal,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Tab {
pos: f64,
align: TabAlign,
}
fn tabs_of(props: &ParaProps) -> Vec<Tab> {
props
.tabs
.as_ref()
.map(|t| {
t.iter()
.map(|s| Tab {
pos: s.pos,
align: match s.align.as_str() {
"ctr" => TabAlign::Center,
"r" => TabAlign::Right,
"dec" => TabAlign::Decimal,
_ => TabAlign::Left,
},
})
.collect()
})
.unwrap_or_default()
}
/// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of
/// that text before its first ".".
fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) {
let mut w = 0.0;
let mut dec = None;
for c in cells.iter().skip(i + 1) {
if matches!(c.ch, '\t' | '\u{b}' | '\n') {
break;
}
if c.ch == '.' && dec.is_none() {
dec = Some(w);
}
w += c.adv;
}
(w, dec.unwrap_or(w))
}
/// Advance of a tab at `x` (from the start edge) to the next stop, placing the text after it
/// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never
/// moving back, as PowerPoint does; past the last stop, to the next default stop.
fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 {
for s in stops {
if *s > x + 0.5 {
return s - x;
if s.pos > x + 0.5 {
let to = s.pos - x;
return match s.align {
TabAlign::Left => to,
TabAlign::Right => (to - seg.0).max(0.0),
TabAlign::Center => (to - seg.0 / 2.0).max(0.0),
TabAlign::Decimal => (to - seg.1).max(0.0),
};
}
}
let d = if default_tab > 1.0 { default_tab } else { 72.0 };
@ -1094,11 +1561,22 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay
l
}
#[allow(clippy::too_many_arguments)]
fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout {
let r = opts.rect;
let vert = bp.vert.unwrap_or(TextDir::Horizontal);
// East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text
// stands every character upright, one under the other.
let upright_text = match vert {
TextDir::EaVertical => Upright::Cjk,
TextDir::Stacked | TextDir::StackedRtl => Upright::All,
_ => Upright::None,
};
// Mongolian vertical and stacked text: turned like vert, their lines run from left to right
// (as PowerPoint draws both).
let lines_ltr = matches!(vert, TextDir::MongolianVertical | TextDir::Stacked);
let rotation = match vert {
TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked => 90.0,
TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::StackedRtl | TextDir::MongolianVertical => 90.0,
TextDir::Vertical270 => 270.0,
TextDir::Horizontal => 0.0,
} + bp.rot.unwrap_or(0.0);
@ -1147,7 +1625,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
}
None
};
shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number));
shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, upright_text));
}
for (pi, p) in shaped_paras.iter().enumerate() {
let props = &p.props;
@ -1166,7 +1644,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
(margin + indent).max(0.0)
};
let rest_x = margin;
let tabs: Vec<f64> = props.tabs.as_ref().map(|t| t.iter().map(|s| s.pos).collect()).unwrap_or_default();
let tabs = tabs_of(props);
let first_w = (col_w - first_x - mr).max(1.0);
let rest_w = (col_w - rest_x - mr).max(1.0);
let raw = break_lines(p, first_w, rest_w, wrap, &tabs, props.default_tab.unwrap_or(72.0), first_x, rest_x);
@ -1179,7 +1657,11 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
for c in p.cells.get(rl.start..rl.end.max(rl.start)).unwrap_or(&[]) {
if let (Some(st), Some(face)) = (p.styles.get(c.style), p.faces.get(c.face)) {
let sz = st.size;
let (a, d) = line_metrics(face, sz);
// The run's own fonts set the line; a fallback face (emoji, a symbol or a
// script the run's fonts lack) does not move the baseline in PowerPoint.
let own = Arc::ptr_eq(face, &st.face)
|| [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.family.eq_ignore_ascii_case(f));
let (a, d) = line_metrics(if own { face } else { &st.face }, sz);
asc = asc.max(a);
desc = desc.max(d);
size = size.max(sz);
@ -1208,8 +1690,16 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
// Logical advances; tabs measure from the start edge.
let mut adv = Vec::with_capacity(n);
let mut x = 0.0;
for c in line_cells {
let a = if c.ch == '\t' { tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0)) } else { c.adv };
// A line that breaks at a soft hyphen ends with a visible hyphen.
let shy_end = li + 1 < raw.len() && line_cells.last().is_some_and(|c| c.ch == '\u{AD}');
for (k, c) in line_cells.iter().enumerate() {
let a = if shy_end && k + 1 == n {
p.faces.get(c.face).zip(p.styles.get(c.style)).map(|(f, st)| measure_str(f, st.size, "-").0).unwrap_or(0.0)
} else if c.ch == '\t' {
tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k))
} else {
c.adv
};
adv.push(a);
x += a;
}
@ -1338,6 +1828,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
}
let total = y;
out.content_height = total;
// Where column `c` starts: from the left, or from the right with `rtlCol` (PowerPoint puts the
// first column on the right).
let rtl_cols = bp.rtl_col.unwrap_or(false) && ncols > 1;
let col_x = |c: usize| (if rtl_cols { ncols.saturating_sub(1 + c) } else { c }) as f64 * (col_w + col_gap);
// Columns: distribute lines by height.
let col_h = if ncols > 1 { inner.height() } else { f64::INFINITY };
let mut col = 0usize;
@ -1353,16 +1847,20 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
li.top -= dy;
li.baseline -= dy;
li.bottom -= dy;
li.shift_x(col as f64 * (col_w + col_gap));
}
let cx = col_x(col);
if cx != 0.0 {
li.shift_x(cx);
}
}
let used_h = if ncols > 1 { all_lines.iter().map(|(l, ..)| l.bottom).fold(0.0, f64::max) } else { total };
out.overflow = used_h > inner.height() + 0.5 || (ncols > 1 && all_lines.last().is_some_and(|(l, ..)| l.bottom > inner.height() + 0.5));
let anchor = bp.anchor.unwrap_or(Anchor::Top);
let dy = match anchor {
Anchor::Top | Anchor::Justified | Anchor::Distributed => 0.0,
Anchor::Top => 0.0,
Anchor::Middle => (inner.height() - used_h) / 2.0,
Anchor::Bottom => inner.height() - used_h,
// PowerPoint draws justified and distributed anchoring at the bottom.
Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h,
};
// anchorCtr: centre the block horizontally.
let dx = if bp.anchor_ctr.unwrap_or(false) { ((inner.width() - out.content_width) / 2.0).max(0.0) } else { 0.0 };
@ -1374,8 +1872,15 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
line.baseline += oy;
line.bottom += oy;
line.shift_x(ox);
if lines_ltr {
// The line's place mirrored across the box: the first line ends up on the left.
let d = inner.y0 + inner.y1 - line.top - line.bottom;
line.top += d;
line.baseline += d;
line.bottom += d;
}
let Some(p) = shaped_paras.get(line.para) else { continue };
if let Some((s, bx, by)) = bullet.take()
if let Some((s, bx, _)) = bullet.take()
&& let Some((_, st)) = p.bullet.as_ref()
{
let (_, g) = measure_str(&st.face, st.size, &s);
@ -1383,10 +1888,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
face: st.face.clone(),
size: st.size,
color: st.color,
glyphs: g
.into_iter()
.map(|(gid, x)| (gid, ox + bx + x + if line.column > 0 { line.column as f64 * (col_w + col_gap) } else { 0.0 }, by + oy))
.collect(),
glyphs: g.into_iter().map(|(gid, x)| (gid, ox + bx + x + col_x(line.column), line.baseline)).collect(),
cells: vec![],
fake_bold: false,
fake_italic: false,
@ -1397,11 +1899,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
alpha: st.alpha,
gradient: None,
effects: None,
upright: false,
});
}
// Group visually consecutive cells by (style, face).
let mut cur: Option<GlyphRun> = None;
let mut cur_key = (usize::MAX, usize::MAX);
let mut cur_key = (usize::MAX, usize::MAX, false, false);
for &k in &place.order {
let ci = line.start + k;
let Some(cell) = p.cells.get(ci) else { continue };
@ -1410,7 +1913,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
let base_y = line.baseline - st.baseline * st.size;
let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size };
let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size };
let key = (cell.style, cell.face);
// Small capitals are drawn smaller than the capitals beside them: a run of their own.
let key = (cell.style, cell.face, st.caps == Caps::Small && cell.ch.is_lowercase(), cell.upright);
if key != cur_key {
if let Some(r) = cur.take() {
out.runs.push(r);
@ -1431,12 +1935,19 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
alpha: st.alpha,
gradient: st.gradient.clone(),
effects: st.effects,
upright: cell.upright,
});
cur_key = key;
}
if let Some(r) = cur.as_mut() {
r.chars = (r.chars.0.min(ci), r.chars.1.max(ci + 1));
if cell.head == ci && !cell.glyphs.is_empty() {
if cell.ch == '\u{AD}' && cx1 - cx > 0.01 {
// The soft hyphen a line breaks at is drawn as a hyphen.
if let Some(f) = p.faces.get(cell.face) {
r.glyphs.push((f.glyph_for('-'), cx, base_y));
r.cells.push((ci, cell.ch));
}
} else if cell.head == ci && !cell.glyphs.is_empty() {
// The cluster's glyphs start at its leftmost cell; kashida (right-to-left
// only) fills the gap on the cluster's left with tatweel glyphs.
let cells_of =
@ -1469,20 +1980,49 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
continue;
}
if let Some(hl) = st.highlight {
out.decos.push(Deco { rect: Rect::new(cx, line.top, cx1, line.bottom), color: hl, behind: true, para: line.para });
out.decos.push(Deco {
rect: Rect::new(cx, line.top, cx1, line.bottom),
color: hl,
behind: true,
para: line.para,
line: DecoLine::Solid,
});
}
if !cell.ch.is_whitespace() || st.underline.is_some() {
if let Some(uc) = st.underline {
let t = (st.size * 0.06).max(0.5);
let uy = line.baseline + st.size * 0.12;
out.decos.push(Deco { rect: Rect::new(cx, uy, cx1, uy + t), color: uc, behind: false, para: line.para });
if let Some((uc, u)) = st.underline {
// At the underline position and thickness of the run's own font.
let k = st.size / st.face.upem.max(1.0);
let (p, t) = (st.face.underline.0 * k, (st.face.underline.1 * k).max(0.5));
for (y0, y1, kind) in u.boxes(p, t) {
out.decos.push(Deco {
rect: Rect::new(cx, line.baseline + y0, cx1, line.baseline + y1),
color: uc,
behind: false,
para: line.para,
line: kind,
});
}
}
if let Some(sk) = st.strike {
let t = (st.size * 0.05).max(0.5);
let sy = line.baseline - st.size * 0.3;
out.decos.push(Deco { rect: Rect::new(cx, sy, cx1, sy + t), color: st.color, behind: false, para: line.para });
// At the strikeout position and thickness of the run's own font.
let k = st.size / st.face.upem.max(1.0);
let t = (st.face.strikeout.1 * k).max(0.5);
let sy = line.baseline - st.face.strikeout.0 * k;
out.decos.push(Deco {
rect: Rect::new(cx, sy, cx1, sy + t),
color: st.color,
behind: false,
para: line.para,
line: DecoLine::Solid,
});
if sk == Strike::Double {
out.decos.push(Deco { rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t), color: st.color, behind: false, para: line.para });
out.decos.push(Deco {
rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t),
color: st.color,
behind: false,
para: line.para,
line: DecoLine::Solid,
});
}
}
}
@ -1511,13 +2051,15 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
fn merge_decos(v: Vec<Deco>) -> Vec<Deco> {
let mut out: Vec<Deco> = Vec::with_capacity(v.len());
for d in v {
if let Some(last) = out.last_mut()
&& last.color == d.color
&& last.behind == d.behind
&& (last.rect.y0 - d.rect.y0).abs() < 0.01
&& (last.rect.y1 - d.rect.y1).abs() < 0.01
&& (last.rect.x1 - d.rect.x0).abs() < 0.5
{
// Lines drawn together (a double underline, a strikethrough) interleave: look a few back.
if let Some(last) = out.iter_mut().rev().take(4).find(|last| {
last.color == d.color
&& last.behind == d.behind
&& last.line == d.line
&& (last.rect.y0 - d.rect.y0).abs() < 0.01
&& (last.rect.y1 - d.rect.y1).abs() < 0.01
&& (last.rect.x1 - d.rect.x0).abs() < 0.5
}) {
last.rect.x1 = d.rect.x1;
continue;
}

View File

@ -101,11 +101,55 @@ fn pairs_are_kerned_only_from_the_kern_size() {
assert!((above - off).abs() < 0.01, "40 pt is below a 48 pt threshold");
}
/// Width of the first paragraph "AVATAR WAVE" (kerned from 12 pt) when a second paragraph's run
/// has character spacing `spc`.
fn kerned_width_beside_spacing(family: &str, spc: f64) -> f64 {
let (p, mut sh) = setup("AVATAR WAVE", 2000.0, 300.0);
let t = sh.text.as_mut().unwrap();
let mut second = t.paragraphs[0].clone();
second.runs[0].props.spacing = Some(spc);
t.paragraphs.push(second);
for para in &mut t.paragraphs {
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 = Some(12.0);
}
}
let l = lay(&p, &sh, 2000.0, 300.0);
l.lines[0].extent.1 - l.lines[0].extent.0
}
#[test]
fn character_spacing_in_the_body_keeps_kerning() {
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;
};
let (_, off) = glyphs_and_width(&family, "AVATAR WAVE", Some(0.0));
let (_, on) = glyphs_and_width(&family, "AVATAR WAVE", Some(12.0));
assert!(on < off);
// Spacing elsewhere in the box leaves the pairs kerned (Calibri, Segoe UI, Times New Roman in
// PowerPoint; only Arial drops them).
for spc in [0.0, 0.5, -1.0, 0.6, 2.0] {
let w = kerned_width_beside_spacing(&family, spc);
assert!((w - on).abs() < 0.01, "{family} spc {spc}: {w} vs {on}");
}
}
#[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 cells = |s: &str| {
s.chars()
.map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true, upright: false })
.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.
@ -264,7 +308,11 @@ fn arabic_face() -> Option<Arc<FontFace>> {
#[test]
fn rtl_mixed_string_is_reordered_per_line() {
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
// An Arabic run (PowerPoint tags typed Arabic `ar-…`): its digits and brackets go with the
// right-to-left text.
let mut run = Run::new(MIXED);
run.props.lang = Some("ar-SA".into());
let (p, sh) = bidi_setup(vec![run], 2000.0, true, Align::Right);
let l = lay(&p, &sh, 2000.0, 300.0);
assert_eq!(l.lines.len(), 1);
let line = &l.lines[0];
@ -293,6 +341,20 @@ fn rtl_mixed_string_is_reordered_per_line() {
assert_eq!(line.levels[10], 2);
}
#[test]
fn an_english_run_keeps_digits_and_brackets_left_to_right() {
// The same text in a run without a right-to-left language: PowerPoint keeps "2024 (Retrait) "
// one left-to-right stretch, its number on the left.
let (p, sh) = bidi_setup(vec![Run::new(MIXED)], 2000.0, true, Align::Right);
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
let mid = |k: usize| (line.edges[k].0 + line.edges[k].1) / 2.0;
assert!(mid(0) > mid(10), "first Arabic word right of the Latin");
assert!(mid(4) < mid(10), "number left of the Latin word");
assert!(mid(9) < mid(10) && mid(17) > mid(16), "brackets around Retrait, unmirrored");
assert!(mid(19) < mid(4), "last Arabic word on the left");
}
#[test]
fn rtl_paragraph_starts_at_the_right_with_the_bullet_outside() {
let (p, mut sh) = bidi_setup(vec![Run::new("مرحبا بكم")], 400.0, true, Align::Right);
@ -781,3 +843,376 @@ fn symbol_font_characters_draw_in_the_sym_font() {
// The letters around it keep the run's font.
assert!(families.iter().all(|(f, c)| c.0 == 1 || !f.eq_ignore_ascii_case(symbol)), "{families:?}");
}
#[test]
fn small_capitals_draw_smaller_than_the_capitals_beside_them() {
let (p, mut sh) = setup("HxHx", 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
para.runs[0].props.caps = Some(Caps::Small);
para.runs[0].props.size = Some(40.0);
let l = lay(&p, &sh, 2000.0, 300.0);
let sizes: Vec<(usize, f64)> = l.runs.iter().map(|r| (r.glyphs.len(), r.size)).collect();
// H at 40 pt, x as a capital at 80 % (four runs: the size changes at every letter).
assert_eq!(sizes, vec![(1, 40.0), (1, 32.0), (1, 40.0), (1, 32.0)]);
}
#[test]
fn capitals_keep_characters_whose_capital_is_a_sequence() {
assert_eq!(caps_char('a'), 'A');
assert_eq!(caps_char('dž'), 'DŽ');
// PowerPoint draws "ß", "fi" and "ʼn" unchanged in all caps.
assert_eq!(caps_char('ß'), 'ß');
assert_eq!(caps_char('\u{FB01}'), '\u{FB01}');
assert_eq!(caps_char('ʼn'), 'ʼn');
let (p, mut sh) = setup("aß", 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.runs[0].props.caps = Some(Caps::All);
let l = lay(&p, &sh, 2000.0, 300.0);
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
let face = &r.face;
if face.covers('ß') {
assert_eq!(r.glyphs[1].0, face.glyph_for('ß'), "ß is not drawn as S");
}
}
#[test]
fn transparent_text_is_faded_once() {
let (p, mut sh) = setup("Faded", 2000.0, 300.0);
let run = &mut sh.text.as_mut().unwrap().paragraphs[0].runs[0];
run.props.fill = Some(deckcraft_model::Fill::solid(
deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)).with(deckcraft_color::ColorTransform::Alpha(40000)),
));
let l = lay(&p, &sh, 2000.0, 300.0);
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
// 60 % transparency: 40 % opaque in all, not 40 % of 40 %.
let opacity = r.color.a as f64 / 255.0 * r.alpha;
assert!((opacity - 0.4).abs() < 0.01, "{opacity}");
}
/// One paragraph `text` with tab stops `tabs` (position, `algn`), no bullet or indent.
fn tabbed(text: &str, tabs: &[(f64, &str)], w: f64) -> TextLayout {
let (p, mut sh) = setup(text, w, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
for r in &mut para.runs {
r.props.size = Some(18.0);
}
let pp = &mut para.props;
pp.bullet = Some(Bullet::None);
pp.margin_left = Some(0.0);
pp.indent = Some(0.0);
pp.default_tab = Some(72.0);
pp.tabs = Some(tabs.iter().map(|(pos, a)| deckcraft_model::text::TabStop { pos: *pos, align: a.to_string() }).collect());
lay(&p, &sh, w, 300.0)
}
#[test]
fn tab_stops_align_the_text_after_them() {
let x0 = |l: &TextLayout| l.inner.x0;
// Right: the text ends at the stop. Centre: centred on it. Decimal: the "." at the stop.
let l = tabbed("a\tright", &[(200.0, "r")], 600.0);
assert!((l.lines[0].extent.1 - x0(&l) - 200.0).abs() < 0.01, "{:?}", l.lines[0].extent);
let l = tabbed("a\tmid", &[(200.0, "ctr")], 600.0);
let (s, e) = (l.lines[0].edges[2].0, l.lines[0].edges[4].1);
assert!(((s + e) / 2.0 - x0(&l) - 200.0).abs() < 0.01);
let l = tabbed("x\t1234.567", &[(150.0, "dec")], 600.0);
assert!((l.lines[0].edges[6].0 - x0(&l) - 150.0).abs() < 0.01, "the point sits at the stop");
// Text without a point ends at a decimal stop.
let l = tabbed("x\t12,5", &[(150.0, "dec")], 600.0);
assert!((l.lines[0].extent.1 - x0(&l) - 150.0).abs() < 0.01);
// Text wider than the room before a right stop starts where the tab is; it never moves back.
let l = tabbed("x\twider than the gap", &[(20.0, "r")], 600.0);
assert!((l.lines[0].edges[2].0 - l.lines[0].edges[0].1).abs() < 0.01);
// A left stop and then default stops every 72 pt.
let l = tabbed("a\tb\tc", &[(30.0, "l")], 600.0);
assert!((l.lines[0].edges[2].0 - x0(&l) - 30.0).abs() < 0.01);
assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01);
}
#[test]
fn a_tab_past_the_end_moves_to_the_next_line() {
// PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next
// line, so the text after it lands on that line's first stop.
let l = tabbed("abcdefghij\tk", &[], 130.0);
assert_eq!(l.lines.len(), 2);
assert_eq!(l.lines[1].start, 10, "the tab starts the second line: {:?}", l.lines.iter().map(|x| (x.start, x.end, x.extent)).collect::<Vec<_>>());
assert!((l.lines[1].edges[1].0 - l.inner.x0 - 72.0).abs() < 0.01);
}
/// Underline decorations of "ab cd" underlined `u` at 40 pt.
fn underline_decos(u: &str) -> (TextLayout, Vec<Deco>) {
let (p, mut sh) = setup("ab cd", 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
para.runs[0].props.size = Some(40.0);
para.runs[0].props.underline = Some(u.into());
let l = lay(&p, &sh, 2000.0, 300.0);
let d = l.decos.iter().filter(|d| !d.behind).cloned().collect();
(l, d)
}
#[test]
fn underlines_sit_at_the_font_underline_in_their_style() {
let (l, sng) = underline_decos("sng");
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
let k = 40.0 / r.face.upem;
let base = l.lines[0].baseline;
assert_eq!(sng.len(), 1);
assert!((sng[0].rect.y0 - base - r.face.underline.0 * k).abs() < 0.01, "top at the font's underline position");
assert!((sng[0].rect.height() - (r.face.underline.1 * k).max(0.5)).abs() < 0.01);
let t = sng[0].rect.height();
// Double: two thin lines spanning from one thickness above the single line to its bottom.
let (_, dbl) = underline_decos("dbl");
assert_eq!(dbl.len(), 2);
assert!((dbl[0].rect.y0 - (sng[0].rect.y0 - t)).abs() < 0.01 && (dbl[1].rect.y1 - sng[0].rect.y1).abs() < 0.01);
// Heavy is thicker; dotted is dots as long as the line is thick.
let (_, heavy) = underline_decos("heavy");
assert!(heavy[0].rect.height() > t * 1.4);
let (_, dotted) = underline_decos("dotted");
assert_eq!(dotted[0].line, DecoLine::Dashed(Dash::Dot));
let pieces = dotted[0].pieces();
assert!(pieces.len() > 3);
assert!((pieces[0].width() - t).abs() < 0.01 && (pieces[1].x0 - pieces[0].x1 - t).abs() < 0.01);
// Wavy: a zigzag between the box's top and bottom.
let (_, wavy) = underline_decos("wavy");
let (pts, w) = wavy[0].wave().expect("a wave");
assert!(pts.len() > 4 && w > 0.0 && w < t);
assert!(pts[0].y < pts[1].y);
// "words" underlines the spaces too, as PowerPoint draws it: one line.
let (_, words) = underline_decos("words");
assert_eq!(words.len(), 1);
}
#[test]
fn no_break_after_a_zero_width_space_or_before_an_em_dash() {
let (allowed, _) = break_opportunities("ab\u{200B}cd", false);
assert!(!allowed[3], "PowerPoint does not break after a zero-width space");
let (allowed, _) = break_opportunities("ab\u{2014}cd", false);
assert!(!allowed[2] && allowed[3], "an em dash ends a line, never starts one");
}
#[test]
fn a_line_broken_at_a_soft_hyphen_shows_a_hyphen() {
let (p, mut sh) = setup("Soft\u{AD}hyphenated\u{AD}words", 150.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
para.runs[0].props.size = Some(24.0);
let l = lay(&p, &sh, 150.0, 300.0);
assert!(l.lines.len() >= 2);
let first = &l.lines[0];
let shy = first.end - 1;
assert_eq!(first.end, 5, "breaks after the soft hyphen: {:?}", l.lines.iter().map(|x| (x.start, x.end)).collect::<Vec<_>>());
let (x0, x1) = first.edges[shy - first.start];
assert!(x1 - x0 > 2.0, "the hyphen takes room");
let r = l.runs.iter().find(|r| r.cells.iter().any(|c| c.0 == shy)).expect("a glyph for the soft hyphen");
assert_eq!(r.glyphs[r.cells.iter().position(|c| c.0 == shy).unwrap()].0, r.face.glyph_for('-'));
// Soft hyphens draw only where a line ends; in a wide box, nowhere.
let ends: Vec<usize> = l.lines.iter().map(|x| x.end.saturating_sub(1)).collect();
for r in &l.runs {
for (g, c) in r.glyphs.iter().zip(&r.cells) {
assert!(c.1 != '\u{AD}' || ends.contains(&c.0) || g.0 != r.face.glyph_for('-'), "mid-line soft hyphen drawn");
}
}
let wide = lay(&p, &sh, 2000.0, 300.0);
assert_eq!(wide.lines.len(), 1);
assert!(wide.runs.iter().all(|r| r.cells.iter().all(|c| c.1 != '\u{AD}' || r.glyphs.iter().all(|g| g.0 != r.face.glyph_for('-')))));
}
#[test]
fn small_kana_and_closing_brackets_follow_powerpoint_kinsoku() {
// "っ" and "ー" never start a line: the character before them moves down too.
let w = one_line("あいうえお") + 0.5;
for (text, first) in [("あいうえおっかき", "あいうえ"), ("あいうえおーかき", "あいうえ"), ("あいうえおかき", "あいうえお")]
{
let lines = wrapped(text, w, |p| p.hanging_punct = Some(true));
assert_eq!(lines[0], first, "{text}: {lines:?}");
}
// A closing bracket doesn't hang like "。" does: "「。」" overflowing by "」" moves down whole.
let w = one_line("あいうえ「。") + 0.5;
let lines = wrapped("あいうえ「。」かき", w, |p| p.hanging_punct = Some(true));
assert_eq!(lines[0], "あいうえ", "{lines:?}");
}
#[test]
fn autonumber_schemes_follow_powerpoint() {
// As PowerPoint's export draws them (start values as in the probe deck).
for (scheme, n, want) in [
("ea1ChsPlain", 4, "四"),
("ea1ChsPeriod", 10, "十."),
("ea1ChtPeriod", 21, "二十一."),
("ea1JpnKorPeriod", 10, "一〇."),
("ea1JpnChsDbPeriod", 13, "一三."),
("arabicDbPlain", 10, "10"),
("arabicDbPeriod", 13, "13."),
("circleNumDbPlain", 13, "⑬"),
("circleNumWdWhitePlain", 13, "③"),
("circleNumWdBlackPlain", 2, "❷"),
("thaiAlphaPeriod", 1, "ก."),
("thaiAlphaParenR", 4, "ง)"),
("thaiAlphaParenBoth", 7, "(ช)"),
("thaiNumPeriod", 10, "๑๐."),
("hindiAlphaPeriod", 4, "ई."),
("hindiAlpha1Period", 1, "क."),
("hindiNumParenR", 13, "१३)"),
("hebrew2Minus", 1, "א-"),
("arabic1Minus", 13, "ش-"),
("arabic2Minus", 10, "ي-"),
("arabicPlain", 10, "10"),
] {
assert_eq!(autonum(scheme, n), want, "{scheme} {n}");
}
assert_eq!(chinese_num(105), "一百零五");
assert_eq!(chinese_num(20), "二十");
// Past the end of an alphabet the letter doubles, like "aa".
assert_eq!(autonum("hebrew2Minus", 23), "אא-");
assert_eq!(autonum("thaiAlphaPeriod", 0), ".");
}
#[test]
fn east_asian_vertical_text_marks_cjk_upright() {
let (p, mut sh) = setup("日本、ABC 12", 300.0, 600.0);
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::EaVertical);
let l = lay(&p, &sh, 300.0, 600.0);
assert_eq!(l.rotation, 90.0);
let upright: Vec<bool> = l
.runs
.iter()
.filter(|r| !r.cells.is_empty())
.flat_map(|r| r.cells.iter().map(move |c| (c.1, r.upright)))
.filter(|(c, _)| *c != ' ')
.map(|(_, u)| u)
.collect();
assert_eq!(upright, vec![true, true, true, false, false, false, false, false]);
// In vert nothing stands upright.
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical);
let l = lay(&p, &sh, 300.0, 600.0);
assert!(l.runs.iter().all(|r| !r.upright));
assert!(is_upright('あ') && is_upright('、') && is_upright('A') && !is_upright('A') && !is_upright('1'));
}
#[test]
fn mongolian_lines_run_left_to_right_and_stacked_letters_stand_one_em_apart() {
let (p, mut sh) = setup("one\ntwo", 200.0, 400.0);
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical);
let v = lay(&p, &sh, 200.0, 400.0);
sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::MongolianVertical);
let m = lay(&p, &sh, 200.0, 400.0);
assert_eq!(m.rotation, 90.0);
// In layout space (turned a quarter turn) vert puts the first line at the top, which shows on
// the right; mongolianVert mirrors the line places, so the first line shows on the left.
assert!(v.lines[0].top < v.lines[1].top);
assert!(m.lines[0].top > m.lines[1].top);
let mid = (m.inner.y0 + m.inner.y1) / 2.0;
assert!(((m.lines[0].top + m.lines[0].bottom) / 2.0 - mid + ((v.lines[0].top + v.lines[0].bottom) / 2.0 - mid)).abs() < 0.01);
// Stacked: every letter upright, one em apart.
let (p, mut sh) = setup("Lil", 200.0, 400.0);
let t = sh.text.as_mut().unwrap();
t.body.vert = Some(deckcraft_model::text::TextDir::Stacked);
t.paragraphs[0].runs[0].props.size = Some(20.0);
let s = lay(&p, &sh, 200.0, 400.0);
assert!(s.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright));
// Its lines run left to right too.
let (p2, mut sh2) = setup("one\ntwo", 200.0, 400.0);
sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Stacked);
let st = lay(&p2, &sh2, 200.0, 400.0);
assert!(st.lines[0].top > st.lines[1].top);
// wordArtVertRtl stacks the same way with lines from right to left.
sh2.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::StackedRtl);
let sr = lay(&p2, &sh2, 200.0, 400.0);
assert!(sr.lines[0].top < sr.lines[1].top);
assert!(sr.runs.iter().filter(|r| !r.cells.is_empty()).all(|r| r.upright));
let e = &s.lines[0].edges;
assert!((e[1].1 - e[1].0 - 20.0).abs() < 0.01 && (e[2].1 - e[2].0 - 20.0).abs() < 0.01, "{e:?}");
}
#[test]
fn symbols_after_an_emoji_keep_a_text_face() {
// "✅ ⚠ ™": PowerPoint draws ✅ (emoji presentation) in colour and the symbols after it as text.
let db = FontDb::global();
if !db.cascade(&['✅'], &[], "Regular").is_color() {
return;
}
let (p, sh) = bidi_setup(vec![Run::new("✅ ⚠ ™")], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
for r in &l.runs {
for (_, c) in &r.cells {
assert_eq!(r.face.is_color(), *c == '✅', "{c:?} drawn with {}", r.face.family);
}
}
}
#[test]
fn fallback_faces_do_not_move_the_baseline() {
// PowerPoint keeps a line's baseline and pitch from the run's own font when an emoji, a symbol
// or a script the font lacks is drawn from another face.
let base = |text: &str| {
let (p, sh) = bidi_setup(vec![Run::new(text)], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
(l.lines[0].baseline, l.lines[0].bottom)
};
let plain = base("Hxg Hxg");
for text in ["Hxg \u{1F600} Hxg", "Hxg \u{0E2A}\u{0E27}\u{0E31}\u{0E2A}", "Hxg \u{222B}\u{2211}"] {
let b = base(text);
assert!((b.0 - plain.0).abs() < 0.01 && (b.1 - plain.1).abs() < 0.01, "{text:?}: {b:?} vs {plain:?}");
}
}
#[test]
fn strikethrough_sits_at_the_font_strikeout_position() {
let (p, mut sh) = setup("Struck", 2000.0, 300.0);
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
para.props.bullet = Some(Bullet::None);
para.runs[0].props.size = Some(96.0);
para.runs[0].props.strike = Some(Strike::Single);
let l = lay(&p, &sh, 2000.0, 300.0);
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
let k = 96.0 / r.face.upem;
let d = l.decos.iter().find(|d| !d.behind).expect("a strikethrough");
assert!((l.lines[0].baseline - d.rect.y0 - r.face.strikeout.0 * k).abs() < 0.01, "top at the strikeout position");
assert!((d.rect.height() - (r.face.strikeout.1 * k).max(0.5)).abs() < 0.01);
}
#[test]
fn rtl_columns_start_on_the_right_and_just_anchoring_sits_at_the_bottom() {
let text = "word ".repeat(200);
let (p, mut sh) = setup(&text, 400.0, 200.0);
let t = sh.text.as_mut().unwrap();
t.body.columns = Some(2);
t.body.col_spacing = Some(20.0);
t.paragraphs[0].runs[0].props.size = Some(12.0);
let ltr = lay(&p, &sh, 400.0, 200.0);
sh.text.as_mut().unwrap().body.rtl_col = Some(true);
let rtl = lay(&p, &sh, 400.0, 200.0);
let first = |l: &TextLayout| l.lines.iter().find(|x| x.column == 0).map(|x| x.extent.0).unwrap();
let second = |l: &TextLayout| l.lines.iter().find(|x| x.column == 1).map(|x| x.extent.0).unwrap();
assert!(first(&ltr) < second(&ltr));
assert!(first(&rtl) > second(&rtl), "with rtlCol the first column is on the right");
// anchor="just" and "dist" draw like "b".
let (p, mut sh) = setup("one line", 400.0, 300.0);
sh.text.as_mut().unwrap().body.anchor = Some(Anchor::Bottom);
let b = lay(&p, &sh, 400.0, 300.0).lines[0].top;
for a in [Anchor::Justified, Anchor::Distributed] {
sh.text.as_mut().unwrap().body.anchor = Some(a);
assert!((lay(&p, &sh, 400.0, 300.0).lines[0].top - b).abs() < 0.01, "{a:?}");
}
}
#[test]
fn stacked_combining_marks_take_a_face_that_draws_them() {
// A letter with marks the run's font lacks draws in a face that has them all (no boxes).
let db = FontDb::global();
let cluster = ['Z', '\u{0334}', '\u{0321}', '\u{031B}'];
let run_face = db.face("Inter", "Regular");
let f = db.cascade(&cluster, std::slice::from_ref(&run_face), "Regular");
if !deckcraft_fonts::covers_cluster(&run_face, &cluster) && ["DejaVu Sans", "Segoe UI"].iter().any(|fam| db.has_family(fam)) {
// Make sure a face with the marks is loaded, then the cluster must take one.
for fam in ["DejaVu Sans", "Segoe UI"] {
if db.has_family(fam) {
let _ = db.face(fam, "Regular");
}
}
let f = db.cascade(&cluster, std::slice::from_ref(&run_face), "Regular");
assert!(deckcraft_fonts::covers_cluster(&f, &cluster), "{} lacks the marks", f.family);
}
let _ = f;
}