Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 9331bc21b0 fix(type): type with a missing font keeps its pixels through transforms (#3617, #3556)
A type layer whose font is not installed no longer re-renders in the
fallback face when it is transformed or resized with the canvas. Like
Photoshop, the layer keeps the pixels it was drawn with (they transform
as pixels, through Free Transform, Image Size and canvas rotation) until
its text is edited; its PSD TySh keeps the file's text data byte for byte
with only the transform updated. Moves already kept the pixels.

Missing-font detection also accepts a stored PostScript name that names
an installed face, so such a run is neither reported missing nor kept as
pixels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 11:48:54 -04:00
8 changed files with 249 additions and 13 deletions

No files matched your search

+5 -1
View File
@@ -66,6 +66,10 @@ fn retag_text(dpi: f32, t: &mut TextLayer) {
if t.psd_raw.is_none() {
return;
}
if crate::type_cmds::keeps_pixels(t) {
crate::type_cmds::retag_transform(t);
return;
}
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let layout = eng.layout(t, dpi);
t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds())));
@@ -187,7 +191,7 @@ pub(crate) fn refresh(doc: &mut Document, what: Refresh) {
match &mut l.content {
LayerContent::Group(g) => rec(snapshot, &mut g.children, what),
LayerContent::Shape(sh) => crate::vector_cmds::refresh_shape(snapshot, sh),
LayerContent::Text(t) if what == Refresh::All => crate::type_cmds::refresh(snapshot, t),
LayerContent::Text(t) if what == Refresh::All => crate::type_cmds::refresh_geometry(snapshot, t),
LayerContent::Smart(_) if what == Refresh::All => {
let _ = crate::smart_cmds::refresh_layer(snapshot, l);
}
+9 -1
View File
@@ -260,6 +260,14 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L
return Err(EngineError::Other("Distort and Perspective need rasterized type (Type › Rasterize Type Layer)".into()));
};
t.transform = a.mul(&t.transform);
// Type whose font is missing transforms its pixels; the rest re-renders afterwards
// (`refresh_text`).
if crate::type_cmds::keeps_pixels(t)
&& let Some(c) = &mut t.cache
{
let src = c.content_bounds();
*c = warp_surface(c, src, h, interp)?;
}
}
LayerContent::Shape(sh) => match affine {
Some(a) => crate::vector_cmds::transform_shape(sh, &a),
@@ -359,7 +367,7 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface
pub(crate) fn refresh_text(doc: &Document, l: &mut Layer) {
match &mut l.content {
LayerContent::Text(t) => crate::type_cmds::refresh(doc, t),
LayerContent::Text(t) => crate::type_cmds::refresh_geometry(doc, t),
LayerContent::Shape(sh) => crate::vector_cmds::refresh_shape(doc, sh),
LayerContent::Smart(_) => {
// Unavailable sources keep the warped cache.
+44
View File
@@ -466,6 +466,50 @@ pub fn refresh(doc: &Document, t: &mut TextLayer) {
t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds())));
}
/// Whether the font database can supply `style`'s font: no family (the default face), an
/// installed family, or a stored PostScript name that names an installed face.
pub(crate) fn font_installed(fonts: &mut photocraft_text::fonts::FontDb, style: &CharStyle) -> bool {
let family = &style.font_family;
if family.is_empty() || fonts.has_family(family) {
return true;
}
style.postscript_name.as_deref().filter(|ps| !ps.is_empty()).is_some_and(|ps| fonts.resolve_postscript(ps).exact)
}
/// Whether any run of `t` uses a font that is not installed.
pub fn uses_missing_font(t: &TextLayer) -> bool {
let runs = t.char_runs();
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
runs.iter().any(|r| !font_installed(&mut eng.fonts, &r.style))
}
/// Whether a geometry-only change (move, transform, canvas) keeps this type layer's pixels
/// instead of re-rendering it: its font is not installed, so a re-render would switch it to the
/// fallback face. Photoshop keeps such a layer as it was drawn until its text is edited; its
/// pixels move and transform as pixels.
pub fn keeps_pixels(t: &TextLayer) -> bool {
t.cache.is_some() && uses_missing_font(t)
}
/// Brings a type layer's PSD `TySh` in line with its current transform without re-laying it
/// out, so a layer whose font is missing keeps its file's text data as it was.
pub fn retag_transform(t: &mut TextLayer) {
if let Some(d) = t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::set_transform(d, &t.transform)) {
t.psd_raw = Some(Arc::new(d));
}
}
/// [`refresh`] after a geometry-only change whose pixels the caller has already mapped. A
/// layer whose font is missing ([`keeps_pixels`]) keeps those pixels; only its `TySh`
/// transform follows.
pub fn refresh_geometry(doc: &Document, t: &mut TextLayer) {
if keeps_pixels(t) {
retag_transform(t);
} else {
refresh(doc, t);
}
}
/// Runs `f` on a type layer as one undo step. `f` may set an explicit layer name (`type.edit`'s
/// `name`), which is applied in the same step; otherwise an auto-named layer follows its text.
fn with_text_layer<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document, &mut Option<String>) -> Result<R>) -> Result<R> {
+9 -9
View File
@@ -415,21 +415,21 @@ 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()
}
/// Font families used by type layers that the font database cannot supply.
/// Font families used by type layers that the font database cannot supply. A run whose family
/// is not installed under that name but whose stored PostScript name names an installed face is
/// not missing.
pub fn missing_fonts(doc: &Document) -> Vec<String> {
let have = installed_families();
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let have: Vec<String> = eng.fonts.families().into_iter().map(|f| f.to_lowercase()).collect();
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);
let f = &r.style.font_family;
if f.is_empty() || have.contains(&f.to_lowercase()) || out.contains(f) || crate::type_cmds::font_installed(&mut eng.fonts, &r.style) {
continue;
}
out.push(r.style.font_family);
}
}
}
+149
View File
@@ -0,0 +1,149 @@
//! A type layer whose font is not installed keeps the pixels it was drawn with when it is moved,
//! transformed or resized with the canvas, as Photoshop does until the text is edited. Before,
//! every transform re-rendered it in the fallback face.
use photocraft_doc::{LayerContent, LayerId, TextLayer};
use photocraft_engine::Session;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
const MISSING: &str = "PhotoCraft Test Face That Is Not Installed";
/// The stand-in for the pixels a PSD stored for the layer: a square no font would draw.
const DRAWN: Rect = Rect { x0: 20, y0: 30, x1: 40, y1: 50 };
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
fn text(s: &Session, id: LayerId) -> TextLayer {
let LayerContent::Text(t) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("not type") };
t.clone()
}
/// A type layer as a PSD with a missing font opens: the font named in the file, the file's
/// pixels (`DRAWN`) and its `TySh` block. With `missing` false the font is an installed one.
fn fixture(missing: bool) -> (Session, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 120, "background": "white"})).unwrap();
let id = LayerId(s.execute("type.create", json!({"text": "Hi", "x": 20, "y": 50, "size": 24, "font": "Inter"})).unwrap()["layer"].as_u64().unwrap());
s.edit("as opened", |doc, _| {
let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) else { panic!("not type") };
if missing {
for r in &mut t.runs {
r.style.font_family = MISSING.into();
r.style.postscript_name = Some("PhotoCraftTestFaceThatIsNotInstalled-Regular".into());
}
t.sync_summary();
}
let mut drawn = Surface::new(t.cache.as_ref().unwrap().format());
drawn.fill_rect(DRAWN, &BLUE);
t.cache = Some(drawn);
Ok(())
})
.unwrap();
(s, id)
}
fn bounds(t: &TextLayer) -> Rect {
t.cache.as_ref().unwrap().content_bounds()
}
fn is_blue(t: &TextLayer, x: i32, y: i32) -> bool {
let c = t.cache.as_ref().unwrap();
(0..4).all(|ch| (c.sample_channel(x, y, ch) - BLUE[ch]).abs() < 0.02)
}
/// The `TySh` transform matches the layer's, and everything after it is the file's.
fn assert_tysh_follows(before: &TextLayer, after: &TextLayer) {
let (b, a) = (before.psd_raw.as_deref().unwrap(), after.psd_raw.as_deref().unwrap());
assert_eq!(photocraft_text::psd::parse_tysh(a).unwrap().transform, after.transform);
assert_eq!(a.get(50..), b.get(50..), "the text data is kept as it was");
}
#[test]
fn moving_keeps_the_pixels() {
let (mut s, id) = fixture(true);
let before = text(&s, id);
s.execute("layer.translate", json!({"layer": id.0, "dx": 15, "dy": 7})).unwrap();
let after = text(&s, id);
assert_eq!(bounds(&after), DRAWN.translate(15, 7), "the pixels moved instead of re-rendering in a fallback face");
assert!(is_blue(&after, 45, 47));
assert_eq!(after.transform.m[4] - before.transform.m[4], 15.0);
assert_eq!(after.transform.m[5] - before.transform.m[5], 7.0);
assert_eq!(after.runs[0].style.font_family, MISSING, "the file's font is kept");
assert_tysh_follows(&before, &after);
// Undo restores the layer as it was.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(bounds(&text(&s, id)), DRAWN);
}
#[test]
fn free_transform_transforms_the_pixels() {
let (mut s, id) = fixture(true);
let before = text(&s, id);
s.execute("edit.transform", json!({"layer": id.0, "matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
let after = text(&s, id);
let b = bounds(&after);
assert!((b.x0 - 40).abs() <= 1 && (b.y0 - 60).abs() <= 1 && (b.x1 - 80).abs() <= 1 && (b.y1 - 100).abs() <= 1, "{b:?}");
assert!(is_blue(&after, 60, 80));
assert!((after.transform.m[0] - 2.0 * before.transform.m[0]).abs() < 1e-9);
assert_tysh_follows(&before, &after);
}
#[test]
fn image_size_resamples_the_pixels() {
let (mut s, id) = fixture(true);
s.execute("image.imageSize", json!({"width": 400, "height": 240})).unwrap();
let after = text(&s, id);
let b = bounds(&after);
assert!((b.x0 - 40).abs() <= 1 && (b.y1 - 100).abs() <= 1, "{b:?}");
assert!(is_blue(&after, 60, 80));
}
#[test]
fn arbitrary_rotation_rotates_the_pixels() {
let (mut s, id) = fixture(true);
s.execute("image.rotation.arbitrary", json!({"angle": 10})).unwrap();
let after = text(&s, id);
let b = bounds(&after);
assert!(b.width() > DRAWN.width() && b.height() > DRAWN.height(), "a rotated square is wider: {b:?}");
let (cx, cy) = ((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2);
assert!(is_blue(&after, cx, cy));
}
#[test]
fn editing_the_text_renders_it_again() {
let (mut s, id) = fixture(true);
s.execute("type.edit", json!({"layer": id.0, "text": "Hello"})).unwrap();
assert_ne!(bounds(&text(&s, id)), DRAWN, "an edit lays the text out (in the substitute face)");
}
#[test]
fn installed_fonts_still_render_again() {
let (mut s, id) = fixture(false);
s.execute("edit.transform", json!({"layer": id.0, "matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
let after = text(&s, id);
assert!(!is_blue(&after, 60, 80), "type in an installed font is laid out again at its new size");
}
#[test]
fn a_matching_postscript_name_is_not_missing() {
let ps = {
let mut eng = photocraft_text::shared().lock().unwrap();
eng.fonts.faces("Inter").into_iter().find_map(|f| f.postscript_name).expect("bundled Inter has a PostScript name")
};
let (mut s, id) = fixture(true);
let missing = s.execute("type.resolveMissingFonts", json!({})).unwrap();
assert_eq!(missing["missing"], json!([MISSING]));
s.edit("name the installed face", |doc, _| {
let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) else { panic!("not type") };
for r in &mut t.runs {
r.style.postscript_name = Some(ps.clone());
}
Ok(())
})
.unwrap();
let missing = s.execute("type.resolveMissingFonts", json!({})).unwrap();
assert_eq!(missing["missing"], json!([]), "the face is installed under its PostScript name");
// And it is not treated as missing: a transform renders it again.
s.execute("edit.transform", json!({"layer": id.0, "matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
assert!(!is_blue(&text(&s, id), 60, 80));
}
+12
View File
@@ -1087,6 +1087,18 @@ pub fn set_text_index(tysh: &[u8], index: i32) -> Option<Vec<u8>> {
Some(write_tysh(&t))
}
/// Rewrites only a `TySh`'s transform (the six doubles after the version), keeping every other
/// byte: the text, its EngineData and the text-space bounds. `None` when `tysh` is too short to
/// hold a transform.
pub fn set_transform(tysh: &[u8], transform: &Affine) -> Option<Vec<u8>> {
let mut out = tysh.to_vec();
let slot = out.get_mut(2..50)?;
for (dst, v) in slot.as_chunks_mut::<8>().0.iter_mut().zip(transform.m) {
dst.copy_from_slice(&v.to_be_bytes());
}
Some(out)
}
/// Updates (or creates) an EngineData tree for `layer`.
pub fn build_engine_data(layer: &TextLayer, template: Option<E>, dpi: f32) -> E {
let k = 72.0 / if dpi > 0.0 { dpi } else { 72.0 };
+15
View File
@@ -1751,3 +1751,18 @@ fn engine_data_antialias_numbers_match_photoshop() {
assert_eq!(back.antialias, mode, "{n}");
}
}
#[test]
fn set_transform_patches_only_the_transform() {
let t = point("Moved", 24.0);
let tysh = crate::psd::build_tysh(&t, 72.0, Some([0.0, -20.0, 60.0, 4.0]));
let a = Affine { m: [2.0, 0.5, -0.5, 2.0, 140.0, -7.5] };
let out = crate::psd::set_transform(&tysh, &a).unwrap();
assert_eq!(out.len(), tysh.len());
assert_eq!(out[..2], tysh[..2]);
assert_eq!(out[50..], tysh[50..], "text, EngineData and bounds are kept byte for byte");
assert_eq!(crate::psd::parse_tysh(&out).unwrap().transform, a);
// Too short to hold a transform: no panic, no result.
assert!(crate::psd::set_transform(&tysh[..20], &a).is_none());
assert!(crate::psd::set_transform(&[], &a).is_none());
}
+6 -2
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: Character panel OpenType buttons landed, TYPE-218-5 stays partial; G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G8: type with a missing font keeps its pixels through transforms) · **Target:** Adobe Photoshop 2026
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
@@ -83,7 +83,10 @@ what an agent can't finish alone.
with PSD round trip (TYPE-218-6, #206), variable-font axes (TYPE-218-4, #2985).
- **Evidence:** scorecard Type: 3 done, 2 partial, 4 missing. 2026-10-11: 2/3/4/5 clicks select
word, line, paragraph and all text, with tests (TYPE-218-8, #3493). The Type tool froze the app for
60 s on one macOS machine (#2518).
60 s on one macOS machine (#2518). 2026-10-12: type whose font is not installed keeps the pixels
it was drawn with through Free Transform, Image Size and canvas rotation until its text is edited,
as in Photoshop (moves already did, #3218); a stored PostScript name that names an installed face
no longer counts as missing.
- **Estimate:** 120–200 h.
### G11. Actions and scripting
@@ -225,6 +228,7 @@ what an agent can't finish alone.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | G8: type with a missing font keeps its drawn pixels through transforms and canvas changes; PostScript-name match counts as installed |
| 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open |
| 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open |
| 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted |