fix(type): missing-font checks follow layout's PostScript resolution; split hyphenless style suffixes (#608)

* fix(text): split hyphenless PostScript style suffixes

A PostScript name without a hyphen kept its style in the guessed family:
`GmarketSansBold` became the family "Gmarket Sans Bold" at weight 400.
The guess now drops a trailing capitalised style word (Gmarket Sans, 700).
A lowercase ending ("Daylight") is not a style, and a style word after the
vendor tag MT stays in the legacy family ("Arial Rounded MT Bold").
Hyphenated names keep the last-hyphen parsing from #3878.

PSD import stores this guess when the exact face is absent. A legacy family
that ends in a style word without MT before it ("CooperBlack") is stored as
the shorter family (Cooper, 900) until the face is installed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(type): missing-font checks follow layout's PostScript resolution

When a run's stored family is not installed, layout draws it with its exact
PostScript face, or with the family guessed from that name if installed.
Missing-font detection compared only the stored family label, so such a run
was reported missing, and Replace Missing Fonts rewrote it whenever an
unresolved run shared the label. missing_fonts, missing_fonts_in_layer and
the replacement now use layout's decision.

#1406 already corrects the label when a PSD or Affinity file is opened with
the face installed, and #1844 finds exact faces outside the family prefix.
The label still stays on a run reopened from .pcraft after its font was
installed, on fonts that arrive after opening, and on a run created with
both "font" and "postscriptName". The tests take the .pcraft and command
paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: kijoong <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 12:48:09 -04:00
1 parent d740ea638d
commit ce6fccc3f5
5 files changed
+193 -12

No files matched your search

+24 -11
View File
@@ -509,20 +509,30 @@ fn update_all(s: &mut Session) -> Result<Value> {
Ok(json!({"updated": n}))
}
fn installed_families() -> Vec<String> {
photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).fonts.families().into_iter().map(|f| f.to_lowercase()).collect()
fn style_font_missing(fonts: &mut photocraft_text::FontDb, style: &photocraft_doc::text::CharStyle) -> bool {
if style.font_family.is_empty() || fonts.has_family(&style.font_family) {
return false;
}
// Match layout's choice: an exact PostScript face wins over the import-time family guess;
// a heuristic family is usable only when the stored family is unavailable.
if let Some(ps) = &style.postscript_name {
let resolved = fonts.resolve_postscript(ps);
if resolved.exact || fonts.has_family(&resolved.family) {
return false;
}
}
true
}
/// Font families used by type layers that the font database cannot supply.
pub fn missing_fonts(doc: &Document) -> Vec<String> {
let have = installed_families();
let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let mut out: Vec<String> = Vec::new();
for (_, _, l) in doc.walk() {
if let LayerContent::Text(t) = &l.content {
for r in t.char_runs() {
let f = r.style.font_family;
if !f.is_empty() && !have.contains(&f.to_lowercase()) && !out.contains(&f) {
out.push(f);
if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) {
out.push(r.style.font_family);
}
}
}
@@ -532,12 +542,11 @@ pub fn missing_fonts(doc: &Document) -> Vec<String> {
/// Font families used by a type layer that the font database cannot supply.
pub fn missing_fonts_in_layer(t: &TextLayer) -> Vec<String> {
let have = installed_families();
let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let mut out: Vec<String> = Vec::new();
for r in t.char_runs() {
let f = r.style.font_family;
if !f.is_empty() && !have.contains(&f.to_lowercase()) && !out.contains(&f) {
out.push(f);
if style_font_missing(&mut engine.fonts, &r.style) && !out.contains(&r.style.font_family) {
out.push(r.style.font_family);
}
}
out
@@ -580,7 +589,11 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map<String, Value>, all: boo
let runs = t.char_runs();
let mut at = 0;
for r in runs {
if let Some(to) = pick(&r.style.font_family) {
let missing_run = {
let mut engine = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
style_font_missing(&mut engine.fonts, &r.style)
};
if missing_run && let Some(to) = pick(&r.style.font_family) {
style_range(t, at, at + r.len, &|st| {
st.font_family = to.clone();
st.postscript_name = None;
@@ -327,3 +327,74 @@ fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() {
assert_eq!(s.active().unwrap().history.past_len(), history);
}
}
/// A type layer stored with a family label that is not installed, as a PSD keeps the family it
/// guessed from a PostScript name whose face was absent when the file was opened.
fn guessed_family_layer(s: &mut Session, text: &str, ps: Option<&str>) -> (LayerId, Value) {
let mut p = json!({"x": 20, "y": 70, "text": text, "font": "Imported Wrong Family"});
if let Some(ps) = ps {
p["postscriptName"] = json!(ps);
}
let r = s.execute("type.create", p).unwrap();
(LayerId(r["layer"].as_u64().unwrap()), r)
}
#[test]
fn installed_postscript_face_is_not_missing_or_replaced() {
// Opening a PSD corrects the label when the face is installed; a run set by command, or one
// reopened from .pcraft after its face was installed, keeps it.
let mut s = session(8);
let (id, created) = guessed_family_layer(&mut s, "test", Some("Inter-SemiBold"));
let bytes = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap();
let reopened = photocraft_io::import("reopened.pcraft", &bytes).unwrap().document;
let LayerContent::Text(t) = &reopened.layer(id).unwrap().content else { panic!("text") };
assert_eq!(t.char_runs()[0].style.font_family, "Imported Wrong Family", ".pcraft open keeps the stored label");
let on_open = missing_font_warnings(&reopened);
s.add_document(reopened, None);
let replaced = s.execute("type.replaceAllMissingFonts", json!({})).unwrap()["replaced"].clone();
assert_eq!((created.get("warnings"), on_open, replaced), (None, Vec::<String>::new(), json!(0)));
}
#[test]
fn replacing_missing_family_preserves_a_resolved_run_with_the_same_label() {
let mut s = session(8);
let (good, _) = guessed_family_layer(&mut s, "good", Some("Inter-SemiBold"));
let (bad, _) = guessed_family_layer(&mut s, "bad", None);
assert_eq!(s.execute("type.replaceAllMissingFonts", json!({})).unwrap()["replaced"], 1);
assert_eq!(text(&s, good).char_runs()[0].style.postscript_name.as_deref(), Some("Inter-SemiBold"));
assert_eq!(text(&s, bad).char_runs()[0].style.font_family, "Inter");
}
/// `source` renamed in memory to `family` with the PostScript name `ps`; no font file is added.
fn renamed_face(source: &[u8], family: &str, ps: &str) -> Vec<u8> {
let mut bytes = source.to_vec();
let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
let record = (0..count).map(|i| 12 + i * 16).find(|r| &bytes[*r..*r + 4] == b"name").unwrap();
let names = [(1u16, family), (2, "Regular"), (6, ps), (16, family), (17, "Regular")];
let mut table: Vec<u8> = [0u16, names.len() as u16, (6 + names.len() * 12) as u16].iter().flat_map(|v| v.to_be_bytes()).collect();
let mut strings = Vec::new();
for (id, value) in names {
let encoded: Vec<u8> = value.encode_utf16().flat_map(u16::to_be_bytes).collect();
for n in [3, 1, 0x409, id, encoded.len() as u16, strings.len() as u16] {
table.extend(n.to_be_bytes());
}
strings.extend(encoded);
}
table.extend(strings);
let offset = bytes.len() as u32;
bytes[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes());
bytes[record + 12..record + 16].copy_from_slice(&(table.len() as u32).to_be_bytes());
bytes.extend(table);
bytes
}
#[test]
fn exact_postscript_face_outside_the_guessed_family_is_not_missing() {
let mut fonts = photocraft_text::FontDb::new();
fonts.register_font_data(renamed_face(&photocraft_text::fonts::INTER_REGULAR, "Exact Only Test", "UnrelatedPS30"));
let style =
|ps: &str| photocraft_doc::text::CharStyle { font_family: "Imported Wrong Family".into(), postscript_name: Some(ps.into()), ..Default::default() };
// Neither the stored family nor the family guessed from the name is installed: only the exact face.
assert!(!fonts.has_family(&photocraft_text::fonts::guess_from_postscript("UnrelatedPS30").family));
assert_eq!((style_font_missing(&mut fonts, &style("UnrelatedPS30")), style_font_missing(&mut fonts, &style("UnrelatedPS31"))), (false, true));
}
+39 -1
View File
@@ -584,10 +584,48 @@ pub fn face_count(bytes: &[u8]) -> usize {
}
}
// CamelCase style suffix of a name without a hyphen (`GmarketSansBold`). Only a capitalised word
// counts ("Daylight" keeps its "light"), and one after a vendor tag stays in a legacy family name
// (`ArialRoundedMTBold` is the family "Arial Rounded MT Bold").
fn split_trailing_style(ps: &str) -> (&str, &str) {
let base = ps.trim_end_matches("MT").trim_end_matches("PS");
let mut family = base;
for suffix in ["Italic", "Oblique"] {
if let Some(rest) = family.strip_suffix(suffix).filter(|s| !s.is_empty()) {
family = rest;
break;
}
}
for suffix in [
"ExtraLight",
"UltraLight",
"ExtraBold",
"UltraBold",
"SemiBold",
"DemiBold",
"Hairline",
"Regular",
"Medium",
"Normal",
"Heavy",
"Black",
"Light",
"Thin",
"Bold",
"Demi",
] {
if let Some(rest) = family.strip_suffix(suffix).filter(|s| !s.is_empty()) {
family = rest;
break;
}
}
if family.len() < base.len() && !family.ends_with("MT") { (family, ps.get(family.len()..).unwrap_or("")) } else { (ps, "") }
}
/// Heuristic PostScript-name split: `MyriadPro-BoldIt` → ("Myriad Pro", 700, italic).
pub fn guess_from_postscript(ps: &str) -> ResolvedFont {
// A vendor or family name may contain hyphens; start with the final face suffix.
let (mut pro, mut style) = ps.rsplit_once('-').unwrap_or((ps, ""));
let (mut pro, mut style) = ps.rsplit_once('-').unwrap_or_else(|| split_trailing_style(ps));
// Keep compound style suffixes the old heuristic understood, such as Bold-It.
// Regular alone must not consume a family component named Bold or Black.
if matches!(style.to_ascii_lowercase().as_str(), "it" | "italic" | "oblique")
+17
View File
@@ -1991,3 +1991,20 @@ fn type_on_a_path_follows_the_curve() {
dot.shape = TextShape::Path { path: Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), start: 0.0, flip: false };
assert_eq!(ink(&mut eng, &dot), flat);
}
#[test]
fn hyphenless_postscript_names_split_trailing_styles() {
for (ps, family, weight, italic) in [
("GmarketSansBold", "Gmarket Sans", 700, false),
("NanumGothicBold", "Nanum Gothic", 700, false),
("GmarketSansTTFBold", "Gmarket Sans TTF", 700, false),
("ExampleSansSemiBoldItalic", "Example Sans", 600, true),
("ExampleSansExtraLight", "Example Sans", 200, false),
// A style word after a vendor tag belongs to a legacy family name; lowercase is no style.
("ArialRoundedMTBold", "Arial Rounded MT Bold", 400, false),
("Daylight", "Daylight", 400, false),
] {
let r = fonts::guess_from_postscript(ps);
assert_eq!((r.family.as_str(), r.weight, r.italic), (family, weight, italic), "{ps}");
}
}
+42
View File
@@ -0,0 +1,42 @@
# PostScript font resolution
PSD text may identify a face by its PostScript name as well as a family/style.
The PostScript prefix need not match the family name, and the family stored with
a run can be a guess made from that name.
## Resolution
`FontDb::resolve_postscript` first looks for an installed face with that exact
PostScript name (families that share the guessed prefix are read first), then
falls back to a heuristic split of the name. The heuristic splits at the last
hyphen. A name without a hyphen loses a trailing capitalised style word
(`GmarketSansBold` → Gmarket Sans, 700; `ExampleSansSemiBoldItalic` → Example
Sans, 600, italic). A lowercase ending is not a style (`Daylight`), and a style
word after the vendor tag `MT` stays in the family (`ArialRoundedMTBold` →
Arial Rounded MT Bold).
The heuristic also gives the family, weight and slant stored on PSD import when
the exact face is absent. A legacy family whose name ends in a style word and
has no `MT` tag before it (`CooperBlack`) is then stored as the shorter family
(Cooper, 900) until the face is installed.
## Missing fonts
Opening a PSD or Affinity file already replaces a run's guessed family with the
installed family its PostScript name resolves to. A guess still stays on a run
when the face was absent at import and the document is reopened from `.pcraft`
after the font is installed, when fonts are registered after opening, or when a
command sets `font` and `postscriptName` together.
Missing-font detection (`missing_fonts` and `missing_fonts_in_layer`) and Replace
Missing Fonts therefore use the same decision as layout: a run is missing only
when neither its stored family, its exact PostScript face nor the heuristic
family is installed. A run that resolves is not replaced because an unresolved
run shares its family label.
## Cost
Detection resolves the PostScript name only for runs whose stored family is not
installed. The first such lookup reads the name tables of installed faces; the
result is cached until fonts are registered or system fonts are loaded, and
layout performs the same lookup for those runs.