Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 a5466741c4 Text: superscript and subscript advance at their drawn size
Superscript and subscript glyphs were drawn at two thirds of the run's size but
advanced by the full size, so the letters spread apart ("s u p e r") and the
line ran wider than in PowerPoint. The cluster advance now uses the same scale
as the glyphs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 13:57:30 -04:00
4 changed files with 48 additions and 91 deletions
-6
View File
@@ -77,12 +77,6 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option<Fill> {
"solidFill" => Fill::Solid { color: color(f).unwrap_or(ColorRef::rgb(Rgba::BLACK)) },
"gradFill" => Fill::Gradient(gradient(f)),
"blipFill" => match blip_fill(imp, part, f) {
// A picture filling a shape or background with neither `a:stretch` nor `a:tile` is
// tiled at its own size, as PowerPoint draws it.
Some(mut p) if f.child("stretch").is_none() && f.child("tile").is_none() => {
p.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
Fill::Picture(p)
}
Some(p) => Fill::Picture(p),
None => Fill::None,
},
-81
View File
@@ -1,81 +0,0 @@
//! Fills read from decks written by other tools.
use std::io::{Cursor, Read, Write};
use deckcraft_geom::Xfrm;
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
use deckcraft_model::{Shape, ShapeId, defaults};
/// `bytes` with `edit` applied to every slide part.
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
let name = f.name().to_string();
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
d = edit(String::from_utf8(d).expect("utf8")).into_bytes();
}
out.start_file(name, o).expect("start");
out.write_all(&d).expect("write");
}
out.finish().expect("finish").into_inner()
}
fn png() -> Vec<u8> {
let mut out = Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
.write_to(&mut out, image::ImageFormat::Png)
.expect("png");
out.into_inner()
}
/// A deck with one shape filled with a stretched picture, exported.
fn deck() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
let media = p.add_media("tile.png", "image/png", png());
let id = ShapeId(p.alloc_id());
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
name: "Textured".into(),
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
fill: Some(Fill::Picture(PictureFill { media, ..Default::default() })),
..Default::default()
});
deckcraft_pptx::export(&p).expect("export")
}
fn textured_mode(bytes: &[u8]) -> PictureMode {
let q = deckcraft_pptx::import(bytes).expect("import");
let s = q.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape");
match &s.fill {
Some(Fill::Picture(pf)) => pf.mode.clone(),
other => panic!("{other:?}"),
}
}
#[test]
fn a_shape_picture_fill_without_stretch_or_tile_tiles() {
// tdf#128596: `<a:blipFill><a:blip/></a:blipFill>` on a shape, as some tools write it;
// PowerPoint tiles the picture at its own size.
let bare = edit_slides(&deck(), |s| {
let s = regex_free_strip(&s, "<a:stretch>", "</a:stretch>");
assert!(!s.contains("<a:stretch"), "the test deck lost its stretch");
s
});
assert!(matches!(textured_mode(&bare), PictureMode::Tile { sx, sy, .. } if sx == 1.0 && sy == 1.0));
// An explicit stretch is still a stretch.
assert!(matches!(textured_mode(&deck()), PictureMode::Stretch { .. }));
}
/// `s` without the first `open`…`close` span inside a `blipFill`.
fn regex_free_strip(s: &str, open: &str, close: &str) -> String {
let Some(b) = s.find("<a:blipFill") else { return s.to_string() };
let Some(i) = s[b..].find(open).map(|i| i + b) else { return s.to_string() };
let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() };
format!("{}{}", &s[..i], &s[j..])
}
+10 -4
View File
@@ -566,6 +566,9 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
cells
}
/// Size of superscript and subscript text relative to the run's size.
const SUB_SUPER: f64 = 0.66;
/// Shape cells `range` (one item) and hang each cluster's glyphs on its first cell, sharing the
/// cluster's advance among its graphemes (by the ligature's GDEF carets when it has them).
#[allow(clippy::too_many_arguments)]
@@ -593,7 +596,11 @@ fn shape_item(
let st = styles.get(cell.style);
let size = st.map(|s| s.size).unwrap_or(18.0);
let small = st.is_some_and(|s| s.caps == Caps::Small) && cell.ch.is_lowercase();
(if small { size * 0.8 } else { size }) / upem
let size = if small { size * 0.8 } else { size };
// Superscript and subscript glyphs are drawn at two thirds of the size, so they advance by
// two thirds too.
let size = if st.is_some_and(|s| s.baseline != 0.0) { size * SUB_SUPER } else { size };
size / upem
};
// Cluster heads in this item, ascending; each cluster spans to the next head.
let mut heads: Vec<usize> = glyphs.iter().filter_map(|g| char_at_byte.get(&g.cluster).copied()).filter(|h| range.contains(h)).collect();
@@ -1155,7 +1162,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
let Some(st) = p.styles.get(cell.style) else { continue };
let Some(&(cx, cx1)) = line.edges.get(k) else { continue };
let base_y = line.baseline - st.baseline * st.size;
let size = if st.baseline != 0.0 { st.size * 0.66 } else { 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);
if key != cur_key {
@@ -1204,9 +1211,8 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
}
}
}
let k66 = if st.baseline != 0.0 { 0.66 } else { 1.0 };
for (gid, gx, gy) in &cell.glyphs {
r.glyphs.push((*gid, cl + kash + gx * k66, base_y + gy));
r.glyphs.push((*gid, cl + kash + gx, base_y + gy));
r.cells.push((ci, cell.ch));
}
}
+38
View File
@@ -395,3 +395,41 @@ fn hostile_bidi_input_never_panics() {
}
}
}
/// Width of `word` laid out in a run with `baseline` (0 = normal, 0.3 = superscript …).
fn run_width(word: &str, baseline: Option<f64>) -> f64 {
let mut run = Run::new(word);
run.props.size = Some(24.0);
run.props.baseline = baseline;
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
line.caret_x.last().unwrap() - line.caret_x.first().unwrap()
}
#[test]
fn superscript_and_subscript_advance_at_their_drawn_size() {
// The glyphs are drawn at two thirds of the size; advancing by the full size spread the
// letters apart ("s u p e r").
let normal = run_width("superscript", None);
for b in [Some(0.3), Some(-0.25)] {
let w = run_width("superscript", b);
assert!((w / normal - SUB_SUPER).abs() < 0.02, "{b:?}: {w} vs {normal}");
}
}
#[test]
fn superscript_glyphs_sit_inside_their_cells() {
let mut run = Run::new("super");
run.props.size = Some(24.0);
run.props.baseline = Some(0.3);
let (p, sh) = bidi_setup(vec![run], 2000.0, false, Align::Left);
let l = lay(&p, &sh, 2000.0, 300.0);
let line = &l.lines[0];
let r = l.runs.iter().find(|r| !r.cells.is_empty()).unwrap();
assert!((r.size - 24.0 * SUB_SUPER).abs() < 1e-6);
for (g, (ci, _)) in r.glyphs.iter().zip(&r.cells) {
let (x0, x1) = line.edges[*ci - line.start];
assert!(g.1 >= x0 - 0.01 && g.1 < x1, "glyph at {} outside its cell {x0}..{x1}", g.1);
}
}