diff --git a/ROADMAP.md b/ROADMAP.md index 15a3914de..4e9b46cda 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,6 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-31** · `font.list` reports the embedding licence of the face that sets each font's text: its OS/2 `fsType` and what the bits mean (`restrictedLicence`, `bitmapOnly`, `noSubsetting`, `editable`, `previewPrint`; null when the font is missing or has no OS/2 table). A script or host that makes PDFs can check every face, bundled and installed ones included, without reading font files. Export behaviour is unchanged. No readiness percentage change. - **2026-10-31** · A `.designcraft` file opened from bytes (`file.openBytes` with its file name, as a browser or host passes it) is titled without the extension, as an IDML file opened that way is. Saving it in the browser downloaded `Brochure.designcraft.designcraft`, and the File Name variable showed the doubled name. Browser Open and Drop pass the full file name, so only the engine removes the extension and a repeated suffix is kept in the title. No readiness percentage change. - **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change. - **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index ee80eb914..77cebe369 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -14,7 +14,7 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ - cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle, replacementFamily, replacementStyle}] (missing first)", has_doc, |s, _| { + cmd!(query "font.list", "Fonts in Document", [], None, "{} → [{family, style, characters, missing, styleMissing, source: bundled|installed|document|added (null when missing), matchStatus: exact|styleSubstitute|missing, resolvedFamily, resolvedStyle, replacementFamily, replacementStyle, fsType, restrictedLicence, bitmapOnly, noSubsetting, editable, previewPrint (the OS/2 fsType embedding bits of the face that sets the text and what they mean; null when missing or the face has no OS/2 table)}] (missing first)", has_doc, |s, _| { Ok(Value::Array(list(&s.doc()?.doc))) }), cmd!( @@ -75,6 +75,19 @@ pub(super) struct UsedFont { pub resolved_style: String, pub replacement_family: String, pub replacement_style: String, + /// The OS/2 `fsType` of the face that sets the text (`resolved_*`); None when missing or the + /// face has no OS/2 table (as are the bits below). + pub fs_type: Option, + /// Restricted License embedding (bit 1 without bits 2–3). + pub restricted_licence: Option, + /// Bitmap embedding only (bit 9). + pub bitmap_only: Option, + /// No subsetting (bit 8): the font may only be embedded whole. + pub no_subsetting: Option, + /// Editable embedding (bit 3). + pub editable: Option, + /// Preview & Print embedding (bit 2). + pub preview_print: Option, } fn list(d: &Document) -> Vec { @@ -124,6 +137,8 @@ pub(super) fn used_fonts(d: &Document) -> Vec { let resolved = db.face(&family, &style); let characters: Vec = characters.into_iter().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); let replacement = db.replacement_face(&family, &style, &characters); + let fs_type = (!missing).then(|| resolved.fs_type()).flatten(); + let bit = |mask: u16| fs_type.map(|t| t & mask != 0); let source = (!missing).then(|| match db.face(&family, &style).source { FontSource::Bundled => "bundled", FontSource::Installed(_) => "installed", @@ -148,6 +163,12 @@ pub(super) fn used_fonts(d: &Document) -> Vec { resolved_style: resolved.style.clone(), replacement_family: replacement.family.clone(), replacement_style: replacement.style.clone(), + fs_type, + restricted_licence: fs_type.map(|_| resolved.restricted_licence()), + bitmap_only: bit(0x0200), + no_subsetting: bit(0x0100), + editable: bit(0x0008), + preview_print: bit(0x0004), } }) .collect(); @@ -297,6 +318,55 @@ mod tests { assert_eq!(before, s.execute("font.list", &json!({})).unwrap()); } + /// `font.list` reports each face's OS/2 fsType and what its bits allow, so a caller can + /// refuse a face whose licence doesn't allow the export it is about to make. + #[test] + fn font_list_reports_the_embedding_licence_bits() { + use designcraft_fonts::testing::{font_with, with_fs_type, with_table_len}; + const FAMILY: &str = "DocFont List FsType"; + const FS_TYPES: [u16; 3] = [0x0002, 0x0108, 0x0204]; + for fs_type in FS_TYPES { + designcraft_fonts::FontDb::global() + .add_font(with_fs_type(font_with(&format!("{FAMILY} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap()); + } + // An OS/2 table cut to nothing can't be read: as if the font had none. + designcraft_fonts::FontDb::global().add_font(with_table_len(font_with(&format!("{FAMILY} no OS/2"), &['W']).unwrap(), b"OS/2", 0).unwrap()); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // "W W W W Plain Gone": each W in one of the fonts above, Plain in the default (bundled) + // font, Gone in a family that isn't available. + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "W W W W Plain Gone"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let set = |s: &mut Session, at: usize, end: usize, family: &str| { + s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": end})).unwrap(); + s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); + }; + for (i, fs_type) in FS_TYPES.into_iter().enumerate() { + set(&mut s, i * 2, i * 2 + 1, &format!("{FAMILY} {fs_type:04x}")); + } + set(&mut s, 6, 7, &format!("{FAMILY} no OS/2")); + set(&mut s, 14, 18, "Nonexistent Sans"); + let l = s.execute("font.list", &json!({})).unwrap(); + let rows = l.as_array().unwrap(); + let entry = |family: &str| rows.iter().find(|f| f["family"] == family).unwrap_or_else(|| panic!("{family}: {l}")); + let bits = |e: &Value| ["fsType", "restrictedLicence", "bitmapOnly", "noSubsetting", "editable", "previewPrint"].map(|k| e[k].clone()); + let (t, f) = (json!(true), json!(false)); + assert_eq!(entry(&format!("{FAMILY} 0002"))["source"], "added", "{l}"); + assert_eq!(bits(entry(&format!("{FAMILY} 0002"))), [json!(0x0002), t.clone(), f.clone(), f.clone(), f.clone(), f.clone()], "{l}"); + assert_eq!(bits(entry(&format!("{FAMILY} 0108"))), [json!(0x0108), f.clone(), f.clone(), t.clone(), t.clone(), f.clone()], "{l}"); + assert_eq!(bits(entry(&format!("{FAMILY} 0204"))), [json!(0x0204), f.clone(), t.clone(), f.clone(), f.clone(), t.clone()], "{l}"); + // The bundled fonts are OFL: installable embedding, every bit clear. + let bundled = rows.iter().find(|r| r["source"] == "bundled").unwrap_or_else(|| panic!("no bundled face: {l}")); + assert_eq!(bits(bundled), [json!(0), f.clone(), f.clone(), f.clone(), f.clone(), f], "{l}"); + // A face without an OS/2 table doesn't say what its licence allows. + assert_eq!(entry(&format!("{FAMILY} no OS/2"))["missing"], false, "{l}"); + assert_eq!(bits(entry(&format!("{FAMILY} no OS/2"))), [(); 6].map(|()| Value::Null), "{l}"); + // A missing face has no file to read. + let missing = entry("Nonexistent Sans"); + assert_eq!(missing["missing"], true, "{l}"); + assert_eq!(bits(missing), [(); 6].map(|()| Value::Null), "{l}"); + } + #[test] fn find_and_replace_font() { let mut s = Session::new(); diff --git a/crates/fonts/src/fontdb/face.rs b/crates/fonts/src/fontdb/face.rs index 058be0c0e..014220a16 100644 --- a/crates/fonts/src/fontdb/face.rs +++ b/crates/fonts/src/fontdb/face.rs @@ -297,16 +297,21 @@ impl FontFace { /// (bit 1) without a less restrictive bit (Preview & Print, Editable). Fonts without an OS/2 /// table aren't restricted. pub fn restricted_licence(&self) -> bool { - use skrifa::raw::TableProvider; - let fs_type = self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type()); + let fs_type = self.fs_type().unwrap_or(0); fs_type & 0x0002 != 0 && fs_type & 0x000C == 0 } /// Does the font's licence allow embedding bitmaps only? OS/2 `fsType` bit 9: the outlines /// may not be passed on, so the font file can't be packaged whole. pub fn bitmap_only(&self) -> bool { + self.fs_type().is_some_and(|t| t & 0x0200 != 0) + } + + /// The font's OS/2 `fsType` (embedding licence bits), None when the font has no OS/2 table it + /// can read: the font doesn't say, which isn't the same as 0 (installable). + pub fn fs_type(&self) -> Option { use skrifa::raw::TableProvider; - self.skrifa().and_then(|f| f.os2().ok()).is_some_and(|t| t.fs_type() & 0x0200 != 0) + self.skrifa().and_then(|f| f.os2().ok()).map(|t| t.fs_type()) } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a41876147..4f2dc5ec2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -293,7 +293,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Column guides: with the Selection tool, drag an interior column guide to move its gutter (both guides together, keeping its width, stopped by the neighbouring guides and the margins), snapping to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels; one undo step. Refused while Lock Column Guides is on (the default). A document page takes the result as its own columns; on a parent page it reaches the pages that follow it. Objects stay where they are (there is no persistent Layout Adjustment setting yet). `layout.moveColumnGuide`. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked. -- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself. +- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` (with each face's OS/2 `fsType` embedding bits) / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself. - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). - Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement through craft-libs/craft-atomic, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original, and already-open readers retain the old bytes. On Unix a new file gets the permissions ordinary file creation gives it (the umask applies) and a replaced file keeps its permissions. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee.