mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 03:36:48 -04:00
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:
5 files changed
+193
-12
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user