mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #439 from NikitkaRa: Pptx: read and write embedded fonts
# Conflicts: # crates/pptx/tests/roundtrip.rs
This commit is contained in:
@@ -138,6 +138,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-13 | Video seeks and loops keep newly decoded frames and end-of-stream notifications; stale generations are filtered when consumed (upstream #416). Deterministic queue coverage and repeated seeks across H.264, HEVC, VP9 and AV1 |
|
||||
| 2026-10-14 | Presenter View handles the same keyboard navigation as the audience window, consumes handled keys before focused buttons, and remains available at the end screen. Regression tests cover presenter-only input, single-step navigation and Escape |
|
||||
| 2026-10-13 | EMF and WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records are still skipped |
|
||||
| 2026-10-13 | Embedded fonts (`p:embeddedFontLst`) are read, written and used to draw. TrueType, OpenType and Embedded OpenType faces load; MicroType Express subsets are kept for the round trip and still draw in a stand-in |
|
||||
| 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-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
|
||||
@@ -169,6 +170,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-13 | trivial | Progress log: video seek frame-loss race and regression coverage |
|
||||
| 2026-10-14 | minor | Presenter keyboard navigation and end-screen controls |
|
||||
| 2026-10-13 | trivial | Progress log: EMF/WMF rectangle clip regions |
|
||||
| 2026-10-13 | trivial | Progress log: embedded fonts |
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
|
||||
@@ -104,6 +104,7 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
|
||||
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
||||
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
crate::fonts::install_embedded_fonts(&p);
|
||||
let missing_fonts = crate::fonts::missing_fonts(&p);
|
||||
let mut st = DocState::new(p, path, title);
|
||||
st.missing_fonts = missing_fonts;
|
||||
|
||||
@@ -6,6 +6,21 @@ use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Bullet, TextBody};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
|
||||
/// Load each embedded face into the font database, so text in that family draws with the file's
|
||||
/// font on a machine that doesn't have it. A face already loaded (bundled or installed) stays;
|
||||
/// the part bytes are kept either way. MicroType Express subsets have no TrueType or OpenType
|
||||
/// font to load and are left for the round trip.
|
||||
pub fn install_embedded_fonts(p: &Presentation) {
|
||||
let db = FontDb::global();
|
||||
for font in &p.embedded_fonts {
|
||||
for id in font.faces().into_iter().flatten() {
|
||||
let Some(item) = p.media(id) else { continue };
|
||||
let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue };
|
||||
db.add_font(sfnt.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included)
|
||||
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive).
|
||||
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
|
||||
@@ -582,6 +582,24 @@ fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() {
|
||||
assert!(s.doc().unwrap().missing_fonts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_face_is_used_when_the_machine_lacks_it() {
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
let mut body = deckcraft_model::text::TextBody::from_text("Hello");
|
||||
body.paragraphs[0].runs[0].props.font = Some("DeckCraft Colr Squares".into());
|
||||
slide.shapes[0].text = Some(body);
|
||||
let id = p.add_media("font1.fntdata", "application/x-fontdata", include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec());
|
||||
p.embedded_fonts.push(deckcraft_model::EmbeddedFont { typeface: "DeckCraft Colr Squares".into(), regular: Some(id), ..Default::default() });
|
||||
let data = crate::cmd::base64_encode(&crate::cmd::file::save_bytes(&p, "pptx").unwrap());
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("file.openBytes", &json!({"name": "fonts.pptx", "data": data})).unwrap();
|
||||
assert_eq!(r["missingFonts"], json!([]));
|
||||
let font = s.doc().unwrap().doc.embedded_fonts.first().unwrap();
|
||||
assert_eq!(font.typeface, "DeckCraft Colr Squares");
|
||||
assert!(font.regular.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() {
|
||||
// Flips don't mirror text, so a click lands on the character drawn under it.
|
||||
|
||||
@@ -40,6 +40,43 @@ pub fn japanese_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Jpan"))
|
||||
}
|
||||
|
||||
/// The TrueType or OpenType font inside a PresentationML embedded-font part
|
||||
/// (`application/x-fontdata`).
|
||||
///
|
||||
/// The part is the font itself, or an Embedded OpenType wrapper around it (the `0x504C` magic
|
||||
/// at byte 34, from the W3C Embedded OpenType submission). A MicroType Express subset is neither,
|
||||
/// so this returns none; the original part stays in the file for the round trip.
|
||||
pub fn embedded_sfnt(part: &[u8]) -> Option<&[u8]> {
|
||||
eot_payload(part).or_else(|| sfnt_or_collection(part).then_some(part))
|
||||
}
|
||||
|
||||
fn sfnt_or_collection(b: &[u8]) -> bool {
|
||||
b.len() >= 12
|
||||
&& (b.starts_with(&[0x00, 0x01, 0x00, 0x00])
|
||||
|| b.starts_with(b"OTTO")
|
||||
|| b.starts_with(b"true")
|
||||
|| b.starts_with(b"typ1")
|
||||
|| b.starts_with(b"ttcf"))
|
||||
}
|
||||
|
||||
/// Font data at the end of an Embedded OpenType blob, when the header is one.
|
||||
fn eot_payload(b: &[u8]) -> Option<&[u8]> {
|
||||
if b.len() < 82 || u16::from_le_bytes([b[34], b[35]]) != 0x504C {
|
||||
return None;
|
||||
}
|
||||
let version = u32::from_le_bytes(b.get(8..12)?.try_into().ok()?);
|
||||
if !matches!(version, 0x0001_0000 | 0x0002_0001 | 0x0002_0002) {
|
||||
return None;
|
||||
}
|
||||
let eot_size = u32::from_le_bytes(b.get(0..4)?.try_into().ok()?) as usize;
|
||||
let font_size = u32::from_le_bytes(b.get(4..8)?.try_into().ok()?) as usize;
|
||||
if font_size < 12 || eot_size < font_size.saturating_add(82) || eot_size > b.len() {
|
||||
return None;
|
||||
}
|
||||
let font = b.get(eot_size - font_size..eot_size)?;
|
||||
sfnt_or_collection(font).then_some(font)
|
||||
}
|
||||
|
||||
/// Japanese faces in document-fallback order: Mincho (serif) families first, matching the
|
||||
/// serif default text font, then the others; Regular before other styles.
|
||||
pub fn japanese_document_fonts() -> Vec<&'static CraftFont> {
|
||||
@@ -357,4 +394,29 @@ mod tests {
|
||||
assert_eq!(db.face("Calibri", "Bold").family, "Carlito");
|
||||
}
|
||||
}
|
||||
|
||||
/// An Embedded OpenType header of `version` around `font`, with the font at the end.
|
||||
fn wrap_eot(font: &[u8], version: u32) -> Vec<u8> {
|
||||
let mut h = vec![0u8; 82];
|
||||
let total = 82 + font.len();
|
||||
h[0..4].copy_from_slice(&(total as u32).to_le_bytes());
|
||||
h[4..8].copy_from_slice(&(font.len() as u32).to_le_bytes());
|
||||
h[8..12].copy_from_slice(&version.to_le_bytes());
|
||||
h[34..36].copy_from_slice(&0x504Cu16.to_le_bytes());
|
||||
h.extend_from_slice(font);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_open_type_yields_the_font() {
|
||||
let font = epaint_default_fonts::UBUNTU_LIGHT;
|
||||
assert_eq!(embedded_sfnt(font), Some(font));
|
||||
assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0002_0001)), Some(font));
|
||||
assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0001_0000)), Some(font));
|
||||
assert!(embedded_sfnt(b"not a font file").is_none());
|
||||
assert!(embedded_sfnt(&wrap_eot(b"hello world!!", 0x0002_0002)).is_none());
|
||||
let mut truncated = wrap_eot(font, 0x0002_0001);
|
||||
truncated.truncate(40);
|
||||
assert!(embedded_sfnt(&truncated).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -802,6 +802,35 @@ pub struct Properties {
|
||||
pub revision: u32,
|
||||
}
|
||||
|
||||
/// One typeface embedded in the presentation (`p:embeddedFont`). Each slot is a font file in
|
||||
/// [`Presentation::media`]: regular, bold, italic, bold italic. A slot is absent when the file
|
||||
/// didn't include that face.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EmbeddedFont {
|
||||
pub typeface: String,
|
||||
/// `panose` on `p:font`, when the file gave the 20 hex digits.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub panose: Option<String>,
|
||||
/// `pitchFamily` on `p:font`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pitch_family: Option<u8>,
|
||||
/// `charset` on `p:font`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub charset: Option<u8>,
|
||||
pub regular: Option<MediaId>,
|
||||
pub bold: Option<MediaId>,
|
||||
pub italic: Option<MediaId>,
|
||||
pub bold_italic: Option<MediaId>,
|
||||
}
|
||||
|
||||
impl EmbeddedFont {
|
||||
/// The four face slots, in the order the package stores them.
|
||||
pub fn faces(&self) -> [Option<MediaId>; 4] {
|
||||
[self.regular, self.bold, self.italic, self.bold_italic]
|
||||
}
|
||||
}
|
||||
|
||||
/// An embedded file (image, audio, video, font, workbook).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -838,8 +867,8 @@ pub struct Presentation {
|
||||
pub first_slide_number: u32,
|
||||
/// Next free shape/slide/layout/media id.
|
||||
pub next_id: u32,
|
||||
/// Embedded fonts (family names; bytes in `media`).
|
||||
pub embedded_fonts: Vec<(String, MediaId)>,
|
||||
/// Embedded fonts. The face bytes live in [`Self::media`].
|
||||
pub embedded_fonts: Vec<EmbeddedFont>,
|
||||
/// Table styles defined in the file (`ppt/tableStyles.xml`).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub table_styles: Vec<table::TableStyleDef>,
|
||||
|
||||
@@ -14,8 +14,8 @@ use std::sync::Arc;
|
||||
use deckcraft_geom::{Size, emu_to_pt};
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{
|
||||
Background, Comment, CustomShow, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
|
||||
Properties, Section, ShowSettings, Slide, SlideId, defaults,
|
||||
Background, Comment, CustomShow, EmbeddedFont, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType,
|
||||
Presentation, Properties, Section, ShowSettings, Slide, SlideId, defaults,
|
||||
};
|
||||
|
||||
use crate::PptxError;
|
||||
@@ -447,12 +447,67 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
|
||||
inherit_placeholder_geometry(&mut p);
|
||||
p.header_footer = header_footer(&p);
|
||||
p.embedded_fonts = read_embedded_fonts(&mut imp, &pres_part, pres_el);
|
||||
p.media = std::mem::take(&mut imp.media);
|
||||
p.next_id = imp.next;
|
||||
p.fix_next_id();
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Embedded font faces in one file. Past this, further typefaces are dropped.
|
||||
const MAX_EMBEDDED_FONTS: usize = 64;
|
||||
/// One font part. A larger part is skipped; the rest of the file is still read.
|
||||
const MAX_FONT_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// `p:embeddedFontLst`: each typeface and the font parts its four face slots point at.
|
||||
fn read_embedded_fonts(imp: &mut Imp, pres: &Part, root: &El) -> Vec<EmbeddedFont> {
|
||||
let Some(list) = root.child("embeddedFontLst") else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for e in list.children_named("embeddedFont").take(MAX_EMBEDDED_FONTS) {
|
||||
let Some(font) = e.child("font") else { continue };
|
||||
let Some(typeface) = font.attr("typeface").map(str::trim).filter(|t| !t.is_empty()) else { continue };
|
||||
let typeface: String = typeface.chars().take(255).collect();
|
||||
let slot = |imp: &mut Imp, name: &str| -> Option<MediaId> {
|
||||
let id = e.child(name)?.attr("r:id")?;
|
||||
let part = pres.rels.target(id)?;
|
||||
let data = imp.pkg.get(part)?;
|
||||
if data.is_empty() || data.len() > MAX_FONT_BYTES {
|
||||
log::warn!("pptx: embedded font {part} skipped ({} bytes)", data.len());
|
||||
return None;
|
||||
}
|
||||
imp.media_for(part)
|
||||
};
|
||||
let regular = slot(imp, "regular");
|
||||
let bold = slot(imp, "bold");
|
||||
let italic = slot(imp, "italic");
|
||||
let bold_italic = slot(imp, "boldItalic");
|
||||
if regular.is_none() && bold.is_none() && italic.is_none() && bold_italic.is_none() {
|
||||
continue;
|
||||
}
|
||||
out.push(EmbeddedFont {
|
||||
typeface,
|
||||
panose: panose_attr(font),
|
||||
pitch_family: byte_attr(font, "pitchFamily"),
|
||||
charset: byte_attr(font, "charset"),
|
||||
regular,
|
||||
bold,
|
||||
italic,
|
||||
bold_italic,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn byte_attr(el: &El, name: &str) -> Option<u8> {
|
||||
u8::try_from(el.u32(name)?).ok()
|
||||
}
|
||||
|
||||
/// `panose` is 20 hex digits (ST_Panose). Anything else is dropped.
|
||||
fn panose_attr(el: &El) -> Option<String> {
|
||||
let v = el.attr("panose")?.trim();
|
||||
(v.len() == 20 && v.chars().all(|c| c.is_ascii_hexdigit())).then(|| v.to_string())
|
||||
}
|
||||
|
||||
fn sane_len(v: f64, dflt: f64) -> f64 {
|
||||
if v.is_finite() && (1.0..=20_000.0).contains(&v) { v } else { dflt }
|
||||
}
|
||||
|
||||
@@ -800,11 +800,99 @@ fn section_slides(p: &Presentation) -> Vec<(String, Vec<usize>)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// One embedded typeface, with the relationship id of each face that had bytes.
|
||||
struct WrittenFont {
|
||||
typeface: String,
|
||||
panose: Option<String>,
|
||||
pitch_family: Option<u8>,
|
||||
charset: Option<u8>,
|
||||
regular: Option<String>,
|
||||
bold: Option<String>,
|
||||
italic: Option<String>,
|
||||
bold_italic: Option<String>,
|
||||
}
|
||||
|
||||
impl WrittenFont {
|
||||
fn any(&self) -> bool {
|
||||
self.regular.is_some() || self.bold.is_some() || self.italic.is_some() || self.bold_italic.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Font parts for `p:embeddedFontLst`. One part per distinct media item, named `ppt/fonts/fontN.fntdata`.
|
||||
fn write_embedded_fonts(x: &mut Exp, o: &mut Out) -> Vec<WrittenFont> {
|
||||
struct Plan {
|
||||
typeface: String,
|
||||
panose: Option<String>,
|
||||
pitch_family: Option<u8>,
|
||||
charset: Option<u8>,
|
||||
regular: Option<MediaId>,
|
||||
bold: Option<MediaId>,
|
||||
italic: Option<MediaId>,
|
||||
bold_italic: Option<MediaId>,
|
||||
}
|
||||
let mut plans = Vec::new();
|
||||
for font in x.p.embedded_fonts.iter().take(64) {
|
||||
let typeface = font.typeface.trim();
|
||||
if typeface.is_empty() {
|
||||
continue;
|
||||
}
|
||||
plans.push(Plan {
|
||||
typeface: typeface.chars().take(255).collect(),
|
||||
panose: font.panose.clone().filter(|s| s.len() == 20 && s.chars().all(|c| c.is_ascii_hexdigit())),
|
||||
pitch_family: font.pitch_family,
|
||||
charset: font.charset,
|
||||
regular: font.regular,
|
||||
bold: font.bold,
|
||||
italic: font.italic,
|
||||
bold_italic: font.bold_italic,
|
||||
});
|
||||
}
|
||||
let mut seen_part: HashMap<MediaId, String> = HashMap::new();
|
||||
let mut n = 0u32;
|
||||
let mut rel = |x: &mut Exp, o: &mut Out, id: Option<MediaId>| -> Option<String> {
|
||||
let id = id?;
|
||||
if let Some(rid) = seen_part.get(&id) {
|
||||
return Some(rid.clone());
|
||||
}
|
||||
let m = x.p.media(id)?;
|
||||
if m.data.is_empty() || m.data.len() > 32 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let bytes = m.data.as_ref().clone();
|
||||
n = n.saturating_add(1);
|
||||
let name = format!("ppt/fonts/font{n}.fntdata");
|
||||
x.pkg.binary(&name, "application/x-fontdata", bytes);
|
||||
let rid = o.rels.add("font", &opc::relative("ppt/presentation.xml", &name), false);
|
||||
seen_part.insert(id, rid.clone());
|
||||
Some(rid)
|
||||
};
|
||||
let mut seen_face = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for font in plans {
|
||||
let written = WrittenFont {
|
||||
typeface: font.typeface,
|
||||
panose: font.panose,
|
||||
pitch_family: font.pitch_family,
|
||||
charset: font.charset,
|
||||
regular: rel(x, o, font.regular),
|
||||
bold: rel(x, o, font.bold),
|
||||
italic: rel(x, o, font.italic),
|
||||
bold_italic: rel(x, o, font.bold_italic),
|
||||
};
|
||||
if written.any() && !seen_face.iter().any(|s| s == &written.typeface) {
|
||||
seen_face.push(written.typeface.clone());
|
||||
out.push(written);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: Option<&str>, slides: &[String]) -> Vec<u8> {
|
||||
let fonts = write_embedded_fonts(x, o);
|
||||
let p = x.p;
|
||||
let mut w = W::new();
|
||||
let first = (p.first_slide_number != 1).then_some(p.first_slide_number.min(9999));
|
||||
root_open(&mut w, "p:presentation", A::new().a("saveSubsetFonts", 1).o("firstSlideNum", first));
|
||||
root_open(&mut w, "p:presentation", A::new().a("saveSubsetFonts", 1).t("embedTrueTypeFonts", !fonts.is_empty()).o("firstSlideNum", first));
|
||||
w.open0("p:sldMasterIdLst");
|
||||
for (id, rid) in masters {
|
||||
w.empty("p:sldMasterId", A::new().a("id", id).a("r:id", rid));
|
||||
@@ -825,6 +913,23 @@ fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes:
|
||||
let clamp = |v: f64| dml::emu(v).clamp(914_400, 51_206_400);
|
||||
w.empty("p:sldSz", A::new().a("cx", clamp(p.slide_size.width)).a("cy", clamp(p.slide_size.height)));
|
||||
w.empty("p:notesSz", A::new().a("cx", dml::emu_pos(p.notes_size.width).max(1)).a("cy", dml::emu_pos(p.notes_size.height).max(1)));
|
||||
if !fonts.is_empty() {
|
||||
w.open0("p:embeddedFontLst");
|
||||
for f in &fonts {
|
||||
w.open0("p:embeddedFont");
|
||||
w.empty(
|
||||
"p:font",
|
||||
A::new().a("typeface", &f.typeface).o("panose", f.panose.clone()).o("pitchFamily", f.pitch_family).o("charset", f.charset),
|
||||
);
|
||||
for (tag, rid) in [("p:regular", &f.regular), ("p:bold", &f.bold), ("p:italic", &f.italic), ("p:boldItalic", &f.bold_italic)] {
|
||||
if let Some(rid) = rid {
|
||||
w.empty(tag, A::new().a("r:id", rid));
|
||||
}
|
||||
}
|
||||
w.close("p:embeddedFont");
|
||||
}
|
||||
w.close("p:embeddedFontLst");
|
||||
}
|
||||
if !p.custom_shows.is_empty() {
|
||||
w.open0("p:custShowLst");
|
||||
for (i, cs) in p.custom_shows.iter().enumerate() {
|
||||
|
||||
@@ -1104,3 +1104,97 @@ fn float_down_is_preset_47_both_ways() {
|
||||
let got: Vec<(&str, &str)> = q.slides[1].animations.iter().map(|a| (a.effect.as_str(), a.option.as_str())).collect();
|
||||
assert_eq!(got, [("float", "d"), ("floatOut", "d"), ("floatOut", "u")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_fonts_round_trip() {
|
||||
let mut p = Presentation::default();
|
||||
let regular = p.add_media("font1.fntdata", "application/x-fontdata", b"REGULAR-FONT-BYTES-0123456789".to_vec());
|
||||
let bold = p.add_media("font2.fntdata", "application/x-fontdata", b"BOLD-FONT-BYTES-0123456789abcd".to_vec());
|
||||
p.embedded_fonts.push(deckcraft_model::EmbeddedFont {
|
||||
typeface: "Deck Test Face".into(),
|
||||
panose: Some("020B0604020202020204".into()),
|
||||
pitch_family: Some(34),
|
||||
charset: Some(0),
|
||||
regular: Some(regular),
|
||||
bold: Some(bold),
|
||||
italic: Some(regular),
|
||||
bold_italic: None,
|
||||
});
|
||||
// The same typeface again is one entry: PowerPoint requires each typeface once.
|
||||
p.embedded_fonts.push(deckcraft_model::EmbeddedFont { typeface: "Deck Test Face".into(), regular: Some(bold), ..Default::default() });
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).expect("zip");
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("ppt/presentation.xml").expect("pres"), &mut xml).expect("read");
|
||||
assert!(xml.contains("embedTrueTypeFonts=\"1\""));
|
||||
let notes = xml.find("<p:notesSz").expect("notesSz");
|
||||
let list = xml.find("<p:embeddedFontLst>").expect("list");
|
||||
let dts = xml.find("<p:defaultTextStyle").expect("style");
|
||||
assert!(notes < list && list < dts);
|
||||
assert!(xml.contains(r#"<p:font typeface="Deck Test Face" panose="020B0604020202020204" pitchFamily="34" charset="0"/>"#));
|
||||
assert_eq!(xml.matches("<p:embeddedFont>").count(), 1);
|
||||
let mut rels = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("ppt/_rels/presentation.xml.rels").expect("rels"), &mut rels).expect("read");
|
||||
assert_eq!(rels.matches("relationships/font").count(), 2, "regular and italic share one part");
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
assert_eq!(q.embedded_fonts.len(), 1);
|
||||
let f = &q.embedded_fonts[0];
|
||||
assert_eq!(f.typeface, "Deck Test Face");
|
||||
assert_eq!(f.panose.as_deref(), Some("020B0604020202020204"));
|
||||
assert_eq!(f.pitch_family, Some(34));
|
||||
assert_eq!(f.charset, Some(0));
|
||||
assert_eq!(q.media(f.regular.expect("regular")).expect("media").data.as_slice(), b"REGULAR-FONT-BYTES-0123456789");
|
||||
assert_eq!(q.media(f.bold.expect("bold")).expect("media").data.as_slice(), b"BOLD-FONT-BYTES-0123456789abcd");
|
||||
assert_eq!(f.italic, f.regular);
|
||||
assert!(f.bold_italic.is_none());
|
||||
}
|
||||
|
||||
/// A package whose font list this writer did not produce: bold only, relationship id not `rIdN`.
|
||||
#[test]
|
||||
fn an_embedded_font_list_already_in_the_package_is_read() {
|
||||
let bytes = deckcraft_pptx::export(&Presentation::default()).expect("export");
|
||||
let font = b"FACE-BYTES-FROM-THE-PACKAGE";
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = vec![];
|
||||
std::io::Read::read_to_end(&mut f, &mut data).expect("read");
|
||||
if name == "ppt/presentation.xml" {
|
||||
let xml = String::from_utf8(data).expect("utf8");
|
||||
let xml = xml.replacen(
|
||||
"<p:defaultTextStyle",
|
||||
concat!(
|
||||
r#"<p:embeddedFontLst><p:embeddedFont><p:font typeface="Package Face" pitchFamily="2"/>"#,
|
||||
r#"<p:bold r:id="rIdFont"/></p:embeddedFont></p:embeddedFontLst><p:defaultTextStyle"#
|
||||
),
|
||||
1,
|
||||
);
|
||||
data = xml.into_bytes();
|
||||
} else if name == "ppt/_rels/presentation.xml.rels" {
|
||||
let xml = String::from_utf8(data).expect("utf8");
|
||||
let xml = xml.replacen(
|
||||
"</Relationships>",
|
||||
r#"<Relationship Id="rIdFont" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/font" Target="fonts/font1.fntdata"/></Relationships>"#,
|
||||
1,
|
||||
);
|
||||
data = xml.into_bytes();
|
||||
} else if name == "[Content_Types].xml" {
|
||||
let xml = String::from_utf8(data).expect("utf8");
|
||||
let xml = xml.replacen("</Types>", r#"<Default Extension="fntdata" ContentType="application/x-fontdata"/></Types>"#, 1);
|
||||
data = xml.into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||
std::io::Write::write_all(&mut out, &data).expect("write");
|
||||
}
|
||||
out.start_file("ppt/fonts/font1.fntdata", zip::write::SimpleFileOptions::default()).expect("font");
|
||||
std::io::Write::write_all(&mut out, font).expect("font bytes");
|
||||
let patched = out.finish().expect("finish").into_inner();
|
||||
let p = deckcraft_pptx::import(&patched).expect("import");
|
||||
let f = p.embedded_fonts.first().expect("font");
|
||||
assert_eq!(f.typeface, "Package Face");
|
||||
assert_eq!(f.pitch_family, Some(2));
|
||||
assert!(f.panose.is_none() && f.regular.is_none() && f.italic.is_none() && f.bold_italic.is_none());
|
||||
assert_eq!(p.media(f.bold.expect("bold")).expect("media").data.as_slice(), font);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (SmartArt text follows `dsp:txXfrm`; previous: trivial, Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width comments: modern threaded parts written, #70; groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (EMF/WMF rectangle clip regions; previous: trivial, groups with a small legacy child space, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (embedded fonts read, written and drawn; MicroType Express subsets kept but not drawn; previously trivial: groups with a small legacy child coordinate space, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
|
||||
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
|
||||
@@ -50,7 +51,7 @@ PowerPoint opens and saves; Viewer = opens only).
|
||||
| Online video (YouTube embeds) | yes | no | Cloud feature |
|
||||
| Embedded OLE objects | yes | kept on round trip (opaque), not insertable or editable | `ShapeKind::Opaque` |
|
||||
| 3-D models (`.glb`) | yes | no | |
|
||||
| Embedded fonts | yes | no | `p:embeddedFontLst` ignored |
|
||||
| Embedded fonts | yes | yes | `p:embeddedFontLst` read and written. TrueType, OpenType and Embedded OpenType faces are used to draw; MicroType Express subsets are kept and written back, not drawn |
|
||||
|
||||
## PPTX coverage (PresentationML / DrawingML)
|
||||
|
||||
@@ -86,7 +87,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Comments: modern threaded (`p188`) | yes | yes | Written next to the flattened legacy part; replies and resolved state round-trip (`threads_and_resolved_round_trip`) |
|
||||
| Unknown slide-level elements (`graphicFrame` OLE, extensions, `mc:AlternateContent`) | kept | kept | Rendered as a labelled placeholder with the preview picture when present |
|
||||
| Tags, custom XML, VBA | no / no / no | | |
|
||||
| Embedded fonts | no | no | |
|
||||
| Embedded fonts | yes | yes | MicroType Express subsets kept, not drawn |
|
||||
| Encryption (password to open) | no | no | |
|
||||
|
||||
### What would move the PPTX number
|
||||
@@ -103,6 +104,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box |
|
||||
| 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain |
|
||||
| 2026-10-13 | minor | Embedded fonts read and written; TrueType, OpenType and Embedded OpenType faces drawn |
|
||||
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
|
||||
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
|
||||
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
|
||||
|
||||
+5
-3
@@ -3,6 +3,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (interactive-deck clicks in the slide show; previous: minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (gap 3 shrinks to media options and rewind: comment threads round-trip now, #70; 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (EMF/WMF rectangle clip regions; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (embedded fonts are read, written and drawn, except MicroType Express subsets; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -125,9 +126,9 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
- **Estimate:** 10–16 h (render the timeline frame by frame; an H.264 encoder is the hard part,
|
||||
FilmCraft has one to copy).
|
||||
|
||||
### Embedded fonts aren't read or written
|
||||
- **Evidence:** no `p:embeddedFontLst` handling in `crates/pptx`.
|
||||
- **Impact:** decks that rely on embedded fonts reflow.
|
||||
### Embedded MicroType Express font subsets aren't drawn
|
||||
- **Evidence:** `p:embeddedFontLst` is read and written. A raw TrueType/OpenType face, or one inside an Embedded OpenType wrapper, is loaded and used to draw. A MicroType Express subset (`application/x-fontdata` with neither form) is kept and written back, and the text still uses a stand-in.
|
||||
- **Impact:** decks that embed a MicroType subset reflow until that face is installed.
|
||||
- **Estimate:** 3–5 h.
|
||||
|
||||
## Features
|
||||
@@ -275,6 +276,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain |
|
||||
| 2026-10-13 | minor | Embedded fonts are read, written and drawn (TrueType, OpenType, Embedded OpenType). The gap is now MicroType Express subsets, which are kept but not drawn |
|
||||
| 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |
|
||||
| 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features |
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# DeckCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (embedded fonts leave the beta list except MicroType Express subsets; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Forward-looking: where DeckCraft is going and in what order. The one-page summary is
|
||||
[ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in
|
||||
@@ -40,7 +40,7 @@ Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx`
|
||||
|---|---|---|---|
|
||||
| 1 | Stability: #60, #40/#59, soak and fuzz the editor | 10–20 | Stability 60 → 85% |
|
||||
| 2 | PPTX real-deck corpus and fixes | 20–35 | File formats 58 → 75% |
|
||||
| 3 | PPTX round-trip losses (#70, #74, #77), `.potx`/`.ppsx`/`.pptm` content types, embedded fonts | 8–15 | File formats → 80% |
|
||||
| 3 | PPTX round-trip losses (#70, #74, #77), `.potx`/`.ppsx`/`.pptm` content types, MicroType embedded-font subsets | 8–15 | File formats → 80% |
|
||||
| 4 | EMF/WMF and SVG pictures | 8–14 | File formats, Pictures |
|
||||
| 5 | Vector PDF, printing | 10–16 | File formats, Files |
|
||||
| 6 | Localization infrastructure + zh-Hans, es, fr, de, ja (with IME) | 27–46 | Localization 8 → ~40% |
|
||||
@@ -109,5 +109,6 @@ plan above.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | trivial | Beta item 3: embedded fonts are in, except MicroType Express subsets |
|
||||
| 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha |
|
||||
| 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history |
|
||||
|
||||
Reference in New Issue
Block a user