mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 04:06:53 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9331bc21b0 |
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user