Integration: track t04

# Conflicts:
#	ATTRIBUTION.md
#	contributors/people.toml
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	docs/gaps.md
This commit is contained in:
aiongg
2026-10-22 15:38:17 +00:00
68 changed files with 4912 additions and 203 deletions
+3 -1
View File
@@ -25,9 +25,10 @@ next to them.
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -36,6 +37,7 @@ next to them.
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+3 -2
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
@@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
| Korean | `ko` | 100% | partial |
| Vietnamese | `vi` | 0% | none |
Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
## Upcoming
@@ -136,6 +136,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own Create Warped Text glyph (not from an icon set): a T above a bowed arc. -->
<path d="M6 5V3h12v2" />
<path d="M12 3v11" />
<path d="M9.5 14h5" />
<path d="M3 17c5 5 13 5 18 0" />
</svg>

After

Width:  |  Height:  |  Size: 419 B

+13
View File
@@ -201,3 +201,16 @@ display_name = "Jason Brashear"
[people.AndreeSalazar]
real_name = "Andreé Salazar"
sync_github_name = true
[people.LorenzoFasolino]
real_name = "Lorenzo Fasolino"
display_name = "Lorenzo Fasolino"
sync_github_name = false
[people.zolyy]
real_name = "Gall Zoltan"
sync_github_name = false
[people.theshubh77]
real_name = "Shubham Bhamare"
sync_github_name = true
+4 -4
View File
@@ -1190,13 +1190,13 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"count": 0, "layers": 0}));
}
s.edit("Replace All Text", |doc, _| {
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
for (id, matches) in &plan {
if let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) {
for (a, b) in matches.iter().rev() {
crate::type_cmds::replace_text(t, *a, *b, &replace);
}
crate::type_cmds::refresh(&snapshot, t);
crate::type_cmds::refresh_at(dpi, format, t);
}
}
Ok(())
@@ -1214,10 +1214,10 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
{
crate::type_cmds::check_text_unlocked(&doc, id)?;
s.edit("Replace Text", |doc, _| {
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) {
crate::type_cmds::replace_text(t, a, b, &replace);
crate::type_cmds::refresh(&snapshot, t);
crate::type_cmds::refresh_at(dpi, format, t);
}
Ok(())
})?;
-1
View File
@@ -1089,7 +1089,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
"plugIns.allowScriptsToConnect",
"plugIns.generatorEnabled",
"type.textEngine",
"type.recentFonts",
"enhancedControls.scrubbySliderAcceleration",
"rawDefaults.colorSpace",
"rawDefaults.bitDepth",
+35 -15
View File
@@ -8,7 +8,7 @@
use std::sync::Arc;
use photocraft_color::Color;
use photocraft_color::{Color, PixelFormat};
use photocraft_doc::text::{
AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection,
TextRun, TextShape,
@@ -250,6 +250,19 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
if let Some(v) = p.get("font").or_else(|| p.get("family")).and_then(Value::as_str) {
s.font_family = v.to_string();
s.postscript_name = None;
// Photoshop accepts either a family name or a face's PostScript name in the font field.
// Resolve only exact PostScript matches here so an unknown family remains untouched.
let mut text = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
if !text.fonts.has_family(v) {
let resolved = text.fonts.resolve_postscript(v);
if resolved.exact {
s.variations.retain(|f| f.axis != "wght");
s.font_family = resolved.family;
s.weight = resolved.weight;
s.italic = resolved.italic;
s.postscript_name = Some(v.to_string());
}
}
hit(true);
}
if let Some(v) = p.get("fontStyle").and_then(Value::as_str) {
@@ -590,9 +603,15 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) {
/// Re-renders the layer's pixels and regenerates its PSD `TySh` data.
pub fn refresh(doc: &Document, t: &mut TextLayer) {
let dpi = doc.resolution_dpi;
refresh_at(doc.resolution_dpi, doc.pixel_format(), t);
}
/// [`refresh`] given only the two things it reads from the document: its resolution and its
/// pixel format. A caller holding the layer mutably reads them first instead of cloning the whole
/// document to pass it, once per keystroke and pointer move while typing (#7092).
pub fn refresh_at(dpi: f32, format: PixelFormat, t: &mut TextLayer) {
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
let (layout, r) = eng.render(t, dpi, doc.pixel_format());
let (layout, r) = eng.render(t, dpi, format);
t.cache = Some(r.surface);
t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds())));
}
@@ -609,11 +628,12 @@ pub(crate) fn check_text_unlocked(doc: &Document, id: LayerId) -> Result<()> {
/// 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> {
/// `f` gets the document's resolution (dpi).
fn with_text_layer<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32, &mut Option<String>) -> Result<R>) -> Result<R> {
let id = layer_id(s, p)?;
check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?;
let (r, damage) = s.edit(label, |doc, _| {
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let auto_named = is_auto_named(l);
let LayerContent::Text(t) = &mut l.content else {
@@ -621,8 +641,8 @@ fn with_text_layer<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&m
};
let before = t.cache.as_ref().map(|c| c.tile_bounds());
let mut rename = None;
let r = f(t, &snapshot, &mut rename)?;
refresh(&snapshot, t);
let r = f(t, dpi, &mut rename)?;
refresh_at(dpi, format, t);
// Only this layer's pixels changed: the canvas recomposites their old and new area
// instead of the whole document (#124). Unknown old pixels mean a full refresh.
let damage = before.zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|(a, b)| a.union(&b));
@@ -689,14 +709,14 @@ pub(crate) fn with_text_targets(
check_text_unlocked(doc, *id)?;
}
let damage = s.edit(label, |doc, _| {
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
let mut damage = Some(photocraft_geom::Rect::EMPTY);
for id in &ids {
let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) else { return Err(EngineError::NoLayer(*id)) };
let before = t.cache.as_ref().map(|c| c.tile_bounds());
let range = if p.get("layers").is_none() || p.get("layer").is_some() { range_param(&t.text, p) } else { (0, t.text.len()) };
f(t, range)?;
refresh(&snapshot, t);
refresh_at(dpi, format, t);
// An unknown old/new cache retains Session.edit's full-refresh fallback.
damage = damage.zip(before).zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|((all, a), b)| all.union(&a).union(&b));
}
@@ -917,7 +937,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
};
let label = if kern_pair.is_some() { "Kerning" } else { "Edit Type" };
with_text_layer(s, p, label, |t, doc, rename| {
with_text_layer(s, p, label, |t, dpi, rename| {
if let Some((at, by)) = kern_pair {
// Photoshop's Alt+←/→: the pair before the caret becomes manually kerned,
// starting from what it shows now (its metrics/optical or manual value).
@@ -929,7 +949,7 @@ pub fn specs() -> Vec<CommandSpec> {
let b = byte_at(&t.text, at);
let now = {
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
eng.pair_kerning(t, doc.resolution_dpi, a)
eng.pair_kerning(t, dpi, a)
}
.ok_or_else(|| bad("type.edit", "no kerning pair at the caret (line break or line end)"))?;
let value = (f64::from(now.round()) + by).clamp(KERN_MIN, KERN_MAX) as f32;
@@ -1045,16 +1065,16 @@ pub fn specs() -> Vec<CommandSpec> {
run: |s, p| {
let id = layer_id(s, p)?;
s.edit("Rasterize Type", |doc, _| {
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let LayerContent::Text(t) = &mut l.content else {
return Err(EngineError::Other(format!("layer {} is not a type layer", id.0)));
};
if t.cache.is_none() {
refresh(&snapshot, t);
refresh_at(dpi, format, t);
}
let surface = t.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(snapshot.pixel_format()));
let surface = if surface.format() == snapshot.pixel_format() { surface } else { surface.convert(snapshot.pixel_format()) };
let surface = t.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(format));
let surface = if surface.format() == format { surface } else { surface.convert(format) };
l.content = LayerContent::Raster(surface);
l.psd_blocks.retain(|(k, _)| k != b"TySh");
Ok(())
+46 -22
View File
@@ -15,7 +15,8 @@ use serde_json::{Value, json};
use crate::commands::{CommandSpec, explicit_layer, layer_param};
use crate::type_cmds::{
check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets,
check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh_at, replace_text, style_paragraphs, style_range,
with_text_targets,
};
use crate::{EngineError, Result, Session};
@@ -68,19 +69,20 @@ fn has_defaults(s: &Session) -> std::result::Result<(), String> {
// ---------- helpers ----------
/// Edits a type layer as one history step and re-renders it.
fn with_text<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result<R>) -> Result<R> {
/// Edits a type layer as one history step and re-renders it. `f` gets the document's resolution
/// (dpi).
fn with_text<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32) -> Result<R>) -> Result<R> {
let id = layer_param(s, p)?;
check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?;
s.edit(label, |doc, _| {
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let auto_named = is_auto_named(l);
let LayerContent::Text(t) = &mut l.content else {
return Err(EngineError::Other(format!("layer {} is {} {} layer, not a type layer", id.0, l.content.article(), l.content.kind_name())));
};
let r = f(t, &snapshot)?;
refresh(&snapshot, t);
let r = f(t, dpi)?;
refresh_at(dpi, format, t);
let name = auto_named.then(|| layer_name(&t.text));
if let Some(n) = name {
l.name = n;
@@ -90,7 +92,11 @@ fn with_text<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Tex
}
fn layout(doc: &Document, t: &TextLayer) -> photocraft_text::TextLayout {
photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).layout(t, doc.resolution_dpi)
layout_at(doc.resolution_dpi, t)
}
fn layout_at(dpi: f32, t: &TextLayer) -> photocraft_text::TextLayout {
photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).layout(t, dpi)
}
/// `m ∘ translate(tx, ty)`.
@@ -144,11 +150,11 @@ fn set_orientation(s: &mut Session, p: &Value, o: Orientation) -> Result<Value>
// ---------- point ⇄ paragraph ----------
fn to_paragraph(s: &mut Session, p: &Value) -> Result<Value> {
let r = with_text(s, p, "Convert to Paragraph Text", |t, doc| {
let l = layout(doc, t);
let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * doc.resolution_dpi / 72.0;
let r = with_text(s, p, "Convert to Paragraph Text", |t, dpi| {
let l = layout_at(dpi, t);
let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * dpi / 72.0;
let ps = t.paragraph_runs().first().map(|p| p.style.clone()).unwrap_or_default();
let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * doc.resolution_dpi / 72.0;
let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * dpi / 72.0;
if l.vertical {
// Line space: u along the columns, v across them (first column centred on v = 0).
let [u0, v0, u1, v1] = l.line_bounds().unwrap_or([0.0, -size_px / 2.0, size_px, size_px / 2.0]);
@@ -178,7 +184,7 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result<Value> {
probe.shape = TextShape::Box { x, y: y0, width, height };
// Box text puts the first baseline at the box top plus the ascender height: measure it and
// shift the box so that baseline stays at the point-text anchor (y = 0).
let b0 = layout(doc, &probe).lines.first().map_or(0.0, |ln| ln.baseline);
let b0 = layout_at(dpi, &probe).lines.first().map_or(0.0, |ln| ln.baseline);
let y = y0 - b0;
t.shape = TextShape::Box { x: 0.0, y: 0.0, width, height };
t.transform = then_translate(&t.transform, f64::from(x), f64::from(y));
@@ -188,9 +194,9 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result<Value> {
}
fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
let r = with_text(s, p, "Convert to Point Text", |t, doc| {
let r = with_text(s, p, "Convert to Point Text", |t, dpi| {
let TextShape::Box { x, y, width, height } = t.shape else { return Err(EngineError::Other("already point text".into())) };
let l = layout(doc, t);
let l = layout_at(dpi, t);
// Vertical type: columns run along the box height.
let (x, width) = if l.vertical { (y, height) } else { (x, width) };
let baseline = l.lines.first().map_or(0.0, |ln| ln.baseline);
@@ -368,13 +374,24 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => false,
Some(v) => v.as_bool().ok_or_else(|| bad("\"flip\" must be true or false"))?,
};
// A document point: the type starts where the path passes nearest it (the Type tool's click).
let at = match p.get("at") {
None | Some(Value::Null) => None,
Some(v) => {
let xy = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
Some(xy.filter(|(x, y)| x.is_finite() && y.is_finite()).ok_or_else(|| bad("\"at\" must be [x, y] in document pixels"))?)
}
};
if at.is_some() && p.get("start").is_some_and(|v| !v.is_null()) {
return Err(bad("pass \"start\" or \"at\", not both"));
}
let id = layer_param(s, p)?;
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
if doc.effective_locks(id).all {
return Err(EngineError::Other("the type layer is locked".into()));
}
let sub = path_source(doc, p)?;
with_text(s, p, "Type on a Path", |t, _| {
let start = with_text(s, p, "Type on a Path", |t, _| {
// The path lives in document space; the type layer keeps it in its own text space, so
// the type lands on the path where it is drawn.
let inv = t.transform.inverse().ok_or_else(|| EngineError::Other("the type layer's transform cannot be inverted".into()))?;
@@ -384,8 +401,15 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result<Value> {
if !path.knots.iter().all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|q| q.x.is_finite() && q.y.is_finite())) {
return Err(EngineError::Other("the path cannot be mapped into the type layer".into()));
}
let start = match at {
None => start,
Some((x, y)) => {
let q = map(Point::new(x, y));
photocraft_text::warp::arc_length_at(&path, flip, q.x, q.y).ok_or_else(|| bad("the path has no length"))? as f32
}
};
t.shape = TextShape::Path { path, start, flip };
Ok(())
Ok(start)
})?;
Ok(json!({"layer": id.0, "start": start, "flip": flip}))
}
@@ -499,11 +523,11 @@ fn paste_lorem(s: &mut Session, p: &Value) -> Result<Value> {
fn update_all(s: &mut Session) -> Result<Value> {
let mut n = 0;
s.edit("Update All Text Layers", |doc, _| {
let snapshot = doc.clone();
let ids: Vec<LayerId> = snapshot.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
let ids: Vec<LayerId> = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect();
for id in ids {
if let Some(Layer { content: LayerContent::Text(t), .. }) = doc.layer_mut(id) {
refresh(&snapshot, t);
refresh_at(dpi, format, t);
n += 1;
}
}
@@ -608,7 +632,7 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map<String, Value>, all: boo
}
let mut n = 0;
s.edit("Replace Missing Fonts", |doc, _| {
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
for id in &ids {
if let Some(Layer { content: LayerContent::Text(t), .. }) = doc.layer_mut(*id) {
let mut changed = false;
@@ -629,7 +653,7 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map<String, Value>, all: boo
at += r.len;
}
if changed {
refresh(&snapshot, t);
refresh_at(dpi, format, t);
n += 1;
}
}
@@ -722,7 +746,7 @@ pub fn specs() -> Vec<CommandSpec> {
"type.attachToPath",
"Type on a Path",
&[],
r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"flip":bool=false (run along the path the other way, on its other side)}"##,
r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"at":[x,y]? (instead of "start": begin where the path passes nearest this document point, as a Type tool click does),"flip":bool=false (run along the path the other way, on its other side)}"##,
has_text,
attach_to_path
),
@@ -314,6 +314,34 @@ fn type_attaches_to_a_path_and_releases_it() {
assert!(matches!(text(&s, id).shape, TextShape::Path { .. }));
}
/// The Type tool's click on a path: `"at"` starts the type where the path passes nearest the
/// click, and the paragraph alignment is kept about that point, as in Photoshop.
#[test]
fn type_attaches_to_a_path_at_a_document_point() {
let mut s = session(8);
s.edit("path", |doc, _| {
doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(10.0, 60.0), (290.0, 60.0)])]));
Ok(())
})
.unwrap();
for (align, centred) in [("left", false), ("center", true)] {
let r = s.execute("type.create", json!({"x": 150, "y": 60, "text": "Path", "size": 24, "align": align, "color": "#000000"})).unwrap();
let id = LayerId(r["layer"].as_u64().unwrap());
assert!(s.execute("type.attachToPath", json!({"at": [150, "y"]})).is_err());
assert!(s.execute("type.attachToPath", json!({"at": [150]})).is_err());
assert!(s.execute("type.attachToPath", json!({"at": [150, 60], "start": 3})).is_err());
let r = s.execute("type.attachToPath", json!({"at": [150, 64]})).unwrap();
assert!((r["start"].as_f64().unwrap() - 140.0).abs() < 0.5, "{r}");
let b = ink(&s, id);
assert!(b.y1 <= 61, "the type sits on the path: {b:?}");
if centred {
assert!(b.x0 < 145 && b.x1 > 155, "centred on the click: {b:?}");
} else {
assert!((148..=156).contains(&b.x0), "starts at the click: {b:?}");
}
}
}
#[test]
fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() {
// #4188: Lock All stopped type.edit (#3132) but not Paste Lorem Ipsum into the layer.
+7 -7
View File
@@ -16,7 +16,7 @@ use photocraft_text::spell::Dictionary;
use serde_json::{Value, json};
use crate::commands::{CommandSpec, layer_param};
use crate::type_cmds::{check_text_unlocked, norm_text, refresh, replace_text};
use crate::type_cmds::{check_text_unlocked, norm_text, refresh_at, replace_text};
use crate::{EngineError, Result, Session};
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
@@ -102,10 +102,10 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("the text at {start}..{end} is {:?}, not {w:?}", &t[a..b])));
}
s.edit("Check Spelling", |doc, _| {
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) {
replace_text(t, a, b, &to);
refresh(&snapshot, t);
refresh_at(dpi, format, t);
}
Ok(())
})?;
@@ -129,13 +129,13 @@ fn change_all(s: &mut Session, p: &Value) -> Result<Value> {
let count: usize = plan.iter().map(|(_, h)| h.len()).sum();
if count > 0 {
s.edit("Check Spelling", |doc, _| {
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
for (id, hits) in &plan {
if let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) {
for (a, b) in hits.iter().rev() {
replace_text(t, *a, *b, &to);
}
refresh(&snapshot, t);
refresh_at(dpi, format, t);
}
}
Ok(())
@@ -215,10 +215,10 @@ fn insert_text(s: &mut Session, p: &Value) -> Result<Value> {
let (ba, bb) = (byte_at(t, a), byte_at(t, b));
let label = p.get("label").and_then(Value::as_str).unwrap_or("Insert Glyph").to_string();
s.edit(&label, |doc, _| {
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) {
replace_text(t, ba, bb, &new);
refresh(&snapshot, t);
refresh_at(dpi, format, t);
}
Ok(())
})?;
+5 -5
View File
@@ -17,7 +17,7 @@ use photocraft_doc::{Document, LayerContent, LayerId, TextLayer, TextStyles};
use serde_json::{Map, Value, json};
use crate::commands::CommandSpec;
use crate::type_cmds::{apply_char_props, apply_para_props, refresh};
use crate::type_cmds::{apply_char_props, apply_para_props, refresh_at};
use crate::{EngineError, Result, Session};
const FAMILY: &str = photocraft_text::fonts::DEFAULT_FAMILY;
@@ -292,7 +292,7 @@ fn commit(s: &mut Session, label: &str, new: TextStyles, target: &[Target], op:
fn apply_styles(doc: &mut Document, new: &TextStyles, target: &[Target], op: Op) -> usize {
let old = doc.text_styles.clone();
doc.text_styles = new.clone();
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
let ids: Vec<LayerId> = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect();
let mut changed = 0;
for id in ids {
@@ -303,7 +303,7 @@ fn apply_styles(doc: &mut Document, new: &TextStyles, target: &[Target], op: Op)
None => restyle(t, &old, new, Some((0, 0)), Op::default()),
};
if did {
refresh(&snapshot, t);
refresh_at(dpi, format, t);
changed += 1;
}
}
@@ -517,7 +517,7 @@ fn delete(s: &mut Session, p: &Value, paragraph: bool) -> Result<Value> {
} else {
doc.text_styles.character.retain(|d| d.id != id);
}
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
let ids: Vec<LayerId> = doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
for lid in ids {
let Some(LayerContent::Text(t)) = doc.layer_mut(lid).map(|l| &mut l.content) else { continue };
@@ -538,7 +538,7 @@ fn delete(s: &mut Session, p: &Value, paragraph: bool) -> Result<Value> {
}
}
if hit {
refresh(&snapshot, t);
refresh_at(dpi, format, t);
n += 1;
}
}
+2 -2
View File
@@ -165,7 +165,7 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<(
}
}
(VarKind::TextReplacement, VarValue::Text(text)) => {
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
if let Some(l) = doc.layer_mut(def.layer)
&& let LayerContent::Text(t) = &mut l.content
{
@@ -180,7 +180,7 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<(
t.sync_summary();
// The compositor draws a type layer from its cache: clearing it left the
// layer blank until some other edit re-rendered it (#990).
crate::type_cmds::refresh(&snapshot, t);
crate::type_cmds::refresh_at(dpi, format, t);
}
}
(VarKind::PixelReplacement { method, align, clip }, VarValue::Pixels(path)) => {
+2 -2
View File
@@ -355,7 +355,7 @@ fn warp_type_layer(s: &mut Session, cmd: &str, id: LayerId, w: &Warp) -> Result<
}
s.edit("Warp", |doc, _| {
let group = crate::transform_cmds::group_locks(doc, id);
let snapshot = doc.clone();
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let locks = l.locks.union(group);
if locks.position || locks.all {
@@ -363,7 +363,7 @@ fn warp_type_layer(s: &mut Session, cmd: &str, id: LayerId, w: &Warp) -> Result<
}
let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other("not a type layer".into())) };
t.warp = tw.clone();
crate::type_cmds::refresh(&snapshot, t);
crate::type_cmds::refresh_at(dpi, format, t);
Ok(())
})?;
Ok(json!({"layer": id.0, "changed": true, "warp": tw}))
@@ -0,0 +1,94 @@
//! `type.create` accepts an exact PostScript face name in its font field.
use photocraft_doc::{LayerContent, LayerId};
use photocraft_engine::Session;
use serde_json::json;
fn font_with_postscript_name(source: &[u8], postscript: &str) -> Vec<u8> {
let mut bytes = source.to_vec();
let count = u16::from_be_bytes(bytes[4..6].try_into().unwrap()) as usize;
let name_record = (0..count).map(|i| 12 + i * 16).find(|r| &bytes[*r..r + 4] == b"name").unwrap();
for i in 0..count {
let r = 12 + i * 16;
let offset = u32::from_be_bytes(bytes[r + 8..r + 12].try_into().unwrap()) as usize;
if &bytes[r..r + 4] == b"OS/2" {
bytes[offset + 4..offset + 6].copy_from_slice(&900u16.to_be_bytes());
}
}
let names = [(1u16, "PhotoCraft Black Test"), (2, "Black"), (6, postscript), (16, "PhotoCraft Black Test"), (17, "Black")];
let mut table = Vec::new();
let mut strings = Vec::new();
for value in [0u16, names.len() as u16, (6 + names.len() * 12) as u16] {
table.extend(value.to_be_bytes());
}
for (id, value) in names {
let encoded: Vec<_> = value.encode_utf16().flat_map(u16::to_be_bytes).collect();
for n in [3u16, 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[name_record + 8..name_record + 12].copy_from_slice(&offset.to_be_bytes());
bytes[name_record + 12..name_record + 16].copy_from_slice(&(table.len() as u32).to_be_bytes());
bytes.extend(table);
bytes
}
#[test]
fn type_create_resolves_exact_postscript_font_names() {
let ps = "PhotoCraft-Black-Test";
photocraft_text::shared()
.lock()
.unwrap_or_else(|e| e.into_inner())
.fonts
.register_font_data(font_with_postscript_name(photocraft_text::fonts::INTER_REGULAR.as_slice(), ps));
let mut session = Session::new();
session.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap();
let response = session.execute("type.create", json!({"text": "Test", "font": ps})).unwrap();
assert!(response["warnings"].as_array().is_none_or(Vec::is_empty), "{response}");
let id = LayerId(response["layer"].as_u64().unwrap());
let LayerContent::Text(text) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") };
let style = &text.char_runs()[0].style;
assert_eq!(style.font_family, "PhotoCraft Black Test");
assert_eq!(style.postscript_name.as_deref(), Some(ps));
assert_eq!(style.weight, 900);
let regular = session.execute("type.create", json!({"text": "Test", "font": "Inter"})).unwrap();
assert!(regular["warnings"].as_array().is_none_or(Vec::is_empty), "{regular}");
let missing = session.execute("type.create", json!({"text": "Test", "font": "No Such Family 123"})).unwrap();
assert_eq!(missing["warnings"], json!(["font 'No Such Family 123' is not installed; text may render using a fallback face"]));
}
#[test]
fn exact_postscript_face_clears_retained_weight_variation() {
let ps = "PhotoCraft-Black-Test-Var";
photocraft_text::shared()
.lock()
.unwrap_or_else(|e| e.into_inner())
.fonts
.register_font_data(font_with_postscript_name(photocraft_text::fonts::INTER_REGULAR.as_slice(), ps));
let mut session = Session::new();
session.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap();
let response = session.execute("type.create", json!({"text": "Test", "font": "Inter", "variations": {"wght": 300}})).unwrap();
let id = LayerId(response["layer"].as_u64().unwrap());
// Setting an exact PostScript face should clear the previously retained wght variation
session.execute("type.setStyle", json!({"layer": id.0, "font": ps})).unwrap();
let LayerContent::Text(text) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") };
let style = &text.char_runs()[0].style;
assert_eq!(style.font_family, "PhotoCraft Black Test");
assert_eq!(style.weight, 900);
assert!(!style.variations.iter().any(|v| v.axis == "wght"));
// Supplying a variations.wght in the same call should apply after clearing
session.execute("type.setStyle", json!({"layer": id.0, "font": ps, "variations": {"wght": 700}})).unwrap();
let LayerContent::Text(text2) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") };
let style2 = &text2.char_runs()[0].style;
assert_eq!(style2.font_family, "PhotoCraft Black Test");
assert_eq!(style2.weight, 900);
assert_eq!(style2.variations.iter().find(|v| v.axis == "wght").map(|v| v.value), Some(700.0));
}
+6 -3
View File
@@ -1,9 +1,12 @@
//! Fonts from the optional craft-fonts build input (https://github.com/storytold/craft-fonts).
//!
//! `CRAFT_FONTS` is empty unless the app was built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`
//! (see `build.rs`); every user of it must work when it is empty. Today it carries the Japanese
//! fonts: BIZ UDPGothic (UI) and Shippori Mincho / BIZ UDMincho (serif document text). The web
//! build (wasm32) embeds none of them: they don't fit its size cap (see `build.rs`).
//! (see `build.rs`); every user of it must work when it is empty. It carries every font in the
//! checkout's `fonts/manifest.txt`, whatever its scripts: the Japanese fonts BIZ UDPGothic (UI) and
//! Shippori Mincho / BIZ UDMincho (serif document text) and, in newer checkouts, fonts for other
//! scripts such as Noto Sans CJK SC, Noto Sans Arabic and Noto Sans Thai.
//! [`crate::fonts::FontDb::new`] registers all of them. The web build (wasm32) embeds none of
//! them: they don't fit its size cap (see `build.rs`).
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
pub struct CraftFont {
+64 -1
View File
@@ -124,7 +124,7 @@ fn collection_isolation_preserves_requested_face() {
let blob = Blob::new(Arc::new(bytes));
assert!(isolated_face(blob.clone(), 2).is_none());
let mut db = FontDb::new();
db.register_blob(blob.clone());
db.register_blob(blob.clone(), None);
let mut style = CharStyle { font_family: "Numeric Test".into(), font_style: "30".into(), ..Default::default() };
db.select_named_face(&mut style);
assert_eq!(db.faces(&style.font_family)[0].postscript_name.as_deref(), Some("Collection30"));
@@ -188,3 +188,66 @@ fn localized_family_names_map_english_families_to_native_names() {
assert!(!local.contains_key(DEFAULT_FAMILY));
assert!(local.values().all(|v| !v.is_empty()));
}
/// A craft-fonts manifest entry for `bytes`, as `build.rs` writes one per manifest line.
fn craft(family: &'static str, scripts: &'static [&'static str], bytes: Vec<u8>) -> crate::CraftFont {
crate::CraftFont { family, style: "Regular", scripts, bytes: bytes.leak() }
}
/// A TrueType collection of single-face fonts.
fn collection(faces: &[Vec<u8>]) -> Vec<u8> {
let mut bytes = b"ttcf\0\x01\0\0".to_vec();
bytes.extend((faces.len() as u32).to_be_bytes());
let mut start = 12 + 4 * faces.len();
for face in faces {
bytes.extend((start as u32).to_be_bytes());
start += face.len();
}
for face in faces {
let start = bytes.len();
bytes.extend(face);
let count = u16::from_be_bytes(bytes[start + 4..start + 6].try_into().unwrap()) as usize;
for i in 0..count {
let r = start + 12 + i * 16 + 8;
let offset = u32::from_be_bytes(bytes[r..r + 4].try_into().unwrap()) + start as u32;
bytes[r..r + 4].copy_from_slice(&offset.to_be_bytes());
}
}
bytes
}
/// Single-face craft fonts of every script register under their manifest family names, whatever
/// family their `name` tables give, and the ones the fallback lists name are in the fallback
/// stack. The Hans and Arab families are fallback-list names, and no craft-fonts manifest uses
/// any of the three, so `FontDb::new` does not register them from the build's own craft fonts.
#[test]
fn craft_fonts_of_every_script_register_under_their_manifest_families() {
let fonts = [
craft("WenQuanYi Micro Hei", &["Hans", "Latn"], localized_font(&INTER_REGULAR, "WenQuanYi Micro Hei", "WenQuanYi Micro Hei")),
// The `name` tables of these two give the family "Numeric Test".
craft("Geeza Pro", &["Arab"], numeric_font(&INTER_REGULAR, "20", "CraftTestArabic")),
craft("Craft Test Gothic", &["Jpan", "Latn"], numeric_font(&INTER_SEMIBOLD, "30", "CraftTestGothic")),
];
let mut db = FontDb::new();
assert!(fonts.iter().all(|f| !db.has_family(f.family)));
db.register_craft_fonts(&fonts);
let families = db.families();
for f in &fonts {
assert!(families.iter().any(|n| n == f.family), "{} registered under its manifest family: {families:?}", f.family);
}
assert!(!db.has_family("Numeric Test"), "{families:?}");
for family in ["WenQuanYi Micro Hei", "Geeza Pro"] {
assert!(db.fallback_stack().any(|f| f == family), "{family} in {:?}", db.fallback_stack().collect::<Vec<_>>());
}
}
/// Each face of a craft font collection keeps the family its `name` table gives.
#[test]
fn craft_font_collections_keep_their_face_families() {
let faces = [localized_font(&INTER_REGULAR, "Craft Test A", "クラフト A"), localized_font(&INTER_SEMIBOLD, "Craft Test B", "クラフト B")];
let fonts = [craft("Craft Test Collection", &["Jpan", "Latn"], collection(&faces))];
let mut db = FontDb::new();
db.register_craft_fonts(&fonts);
assert!(db.has_family("Craft Test A") && db.has_family("Craft Test B"), "{:?}", db.families());
assert!(!db.has_family("Craft Test Collection"));
}
+20 -10
View File
@@ -1,6 +1,6 @@
//! Font database: bundled fonts (always available, also on the web), the optional craft-fonts
//! Japanese fonts ([`crate::craft_fonts`], when built with `CRAFT_FONTS_DIR`), optional system
//! fonts found by scanning platform directories (plus Fontconfig's file list on Linux),
//! fonts of every script ([`crate::craft_fonts`], when built with `CRAFT_FONTS_DIR`), optional
//! system fonts found by scanning platform directories (plus Fontconfig's file list on Linux),
//! user-registered font data, and PostScript-name lookup for PSD import.
use std::collections::HashMap;
@@ -225,14 +225,22 @@ impl FontDb {
db.register_static_font(bytes.as_slice());
}
// The optional craft-fonts (empty unless built with CRAFT_FONTS_DIR; always empty on
// wasm32), before any system font.
for f in crate::craft_fonts::CRAFT_FONTS.iter().filter(|f| f.is_japanese()) {
db.register_static_font(f.bytes);
}
// wasm32), every script, before any system font.
db.register_craft_fonts(crate::craft_fonts::CRAFT_FONTS);
db.refresh_generics();
db
}
/// Registers craft-fonts manifest entries of every script. A single-face file registers under
/// its manifest family, whatever family its `name` table gives. Each face of a collection
/// (TTC/OTC) keeps the family its `name` table gives.
fn register_craft_fonts(&mut self, fonts: &[crate::CraftFont]) {
for f in fonts {
let family = (face_count(f.bytes) == 1).then_some(f.family);
self.register_blob(Blob::new(Arc::new(f.bytes)), family);
}
}
/// Bundled fonts plus the fonts installed on this machine (no-op on the web).
pub fn with_system_fonts() -> Self {
let mut db = Self::new();
@@ -279,16 +287,18 @@ impl FontDb {
/// Registers font data (TTF/OTF, or every face of a TTC/OTC). Returns the family names added.
pub fn register_font_data(&mut self, bytes: Vec<u8>) -> Vec<String> {
self.register_blob(Blob::new(Arc::new(bytes)))
self.register_blob(Blob::new(Arc::new(bytes)), None)
}
/// Registers embedded font data without copying it.
fn register_static_font(&mut self, bytes: &'static [u8]) -> Vec<String> {
self.register_blob(Blob::new(Arc::new(bytes)))
self.register_blob(Blob::new(Arc::new(bytes)), None)
}
fn register_blob(&mut self, blob: Blob<u8>) -> Vec<String> {
let added = self.fcx.collection.register_fonts(blob, None);
/// Registers every face in `blob`, under `family` when given, else under the family each
/// face's `name` table gives.
fn register_blob(&mut self, blob: Blob<u8>, family: Option<&str>) -> Vec<String> {
let added = self.fcx.collection.register_fonts(blob, family.map(|f| FontInfoOverride { family_name: Some(f), ..Default::default() }));
let mut names = Vec::new();
for (id, _) in added {
if let Some(n) = self.fcx.collection.family_name(id)
+2 -1
View File
@@ -4,7 +4,8 @@
//! web), optional system fonts from platform directory scans plus Fontconfig's Linux file list,
//! user font data, TrueType collections, PostScript-name lookup. Builds made with the optional
//! craft-fonts input
//! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its Japanese fonts as fallbacks.
//! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its fonts of every script (a single-face
//! file under its manifest family name); the ones the fallback lists name are fallbacks.
//! * [`layout`]: shaping and line layout with [parley] (HarfRust shaping, bidi, line breaking),
//! point and paragraph (box) text, per-run styles, Photoshop leading/indent/spacing rules.
//! * [`render`]: anti-aliased rasterization (exact-area accumulation, f32 coverage) into a
+19 -11
View File
@@ -76,8 +76,13 @@ pub struct Coverage {
}
impl Coverage {
pub fn new(width: usize, height: usize) -> Self {
Self { width, height, acc: vec![0.0; width * height + 2] }
/// A zeroed accumulator, or `None` when its bytes cannot be reserved.
pub fn try_new(width: usize, height: usize) -> Option<Self> {
let n = width.checked_mul(height)?.checked_add(2)?;
let mut acc = Vec::new();
acc.try_reserve_exact(n).ok()?;
acc.resize(n, 0.0);
Some(Self { width, height, acc })
}
/// Adds a line segment (pixel coordinates, y down).
@@ -148,15 +153,18 @@ impl Coverage {
}
/// Resolves the accumulation into per-pixel coverage in 0..=1.
pub fn finish(self) -> Vec<f32> {
/// The accumulated coverage, or `None` when the output cannot be reserved. The input
/// accumulator is still live while this builds, so both count toward a caller's budget.
pub fn finish(self) -> Option<Vec<f32>> {
let n = self.width * self.height;
let mut out = Vec::with_capacity(n);
let mut out = Vec::new();
out.try_reserve_exact(n).ok()?;
let mut sum = 0.0f32;
for v in &self.acc[..n] {
sum += v;
out.push(sum.abs().min(1.0));
}
out
Some(out)
}
}
@@ -240,9 +248,9 @@ mod tests {
#[test]
fn square_area_exact() {
let mut c = Coverage::new(10, 10);
let mut c = Coverage::try_new(10, 10).unwrap();
rect_to(&mut c, &Xform::IDENTITY, 2.25, 3.5, 6.75, 8.0);
let v = c.finish();
let v = c.finish().unwrap();
assert!((sum(&v) - 4.5 * 4.5).abs() < 1e-3, "{}", sum(&v));
assert_eq!(v[5 * 10 + 4], 1.0);
assert_eq!(v[0], 0.0);
@@ -250,13 +258,13 @@ mod tests {
#[test]
fn triangle_area_and_clipping() {
let mut c = Coverage::new(8, 8);
let mut c = Coverage::try_new(8, 8).unwrap();
// Right triangle partly outside on the left and bottom.
let pts = [(-4.0, 2.0), (6.0, 2.0), (-4.0, 12.0)];
for i in 0..3 {
c.line(pts[i], pts[(i + 1) % 3]);
}
let v = c.finish();
let v = c.finish().unwrap();
// Visible part: region x in [0,6], y in [2,8] under the hypotenuse x + y <= 8.
// Area = ∫_{y=2}^{8} (8 - y) dy = 18.
assert!((sum(&v) - 18.0).abs() < 1e-3, "{}", sum(&v));
@@ -266,7 +274,7 @@ mod tests {
#[test]
fn circle_area_via_pen() {
use skrifa::outline::OutlinePen;
let mut c = Coverage::new(40, 40);
let mut c = Coverage::try_new(40, 40).unwrap();
{
let mut p = Pen::new(&mut c, Xform([1.0, 0.0, 0.0, -1.0, 20.0, 20.0]));
// Cubic circle approximation, r = 15.
@@ -278,7 +286,7 @@ mod tests {
p.curve_to(k, -r, r, -k, r, 0.0);
p.close();
}
let a = sum(&c.finish());
let a = sum(&c.finish().unwrap());
let exact = std::f32::consts::PI * 225.0;
assert!((a - exact).abs() / exact < 0.005, "{a} vs {exact}");
}
+78 -7
View File
@@ -21,6 +21,40 @@ pub const FAUX_ITALIC_DEG: f32 = 12.0;
pub const FAUX_BOLD_RADIUS: f32 = 0.036;
/// Largest raster we produce (pixels), as a guard against absurd sizes.
const MAX_PIXELS: u64 = 256 * 1024 * 1024;
/// Peak working bytes one rasterization may keep live. [`MAX_PIXELS`] does not bound them: the
/// accumulator and the output surface are both `stride` deep, and a faux-bold pass keeps one more
/// coverage buffer live, so a CMYK bold render reserves several times what a plain grey one does
/// for the same pixels.
const MAX_RASTER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 };
/// Whether a `w` x `h` render's peak working bytes fit `max_bytes`: the f32 accumulator and the
/// output surface (both `stride` deep), plus the coverage buffers live at the peak. A plain pass
/// peaks inside `Coverage::finish`, which holds its accumulator and the vector it builds beside
/// it; a faux-bold pass peaks one buffer higher, since the resolved plain coverage is kept while
/// the bold pass resolves into it. `false` when the arithmetic overflows, so an absurd size is
/// refused rather than wrapping.
fn fits_raster_budget(w: usize, h: usize, stride: usize, sample: SampleType, bold: bool, max_bytes: u64) -> bool {
let f32s = size_of::<f32>() as u64;
let peak = (w as u64).checked_mul(h as u64).and_then(|px| {
let deep = px.checked_mul(stride as u64)?.checked_mul(f32s + sample.bytes() as u64)?;
let coverage = px.checked_mul(if bold { 3 } else { 2 })?.checked_mul(f32s)?;
deep.checked_add(coverage)
});
peak.is_some_and(|b| b <= max_bytes)
}
/// A zeroed buffer, or `None` when its bytes cannot be reserved.
fn zeroed(len: usize) -> Option<Vec<f32>> {
let mut v = Vec::new();
v.try_reserve_exact(len).ok()?;
v.resize(len, 0.0);
Some(v)
}
/// What a refused or unrenderable layout returns.
fn nothing(format: PixelFormat) -> Rendered {
Rendered { surface: Surface::new(format), rect: Rect::new(0, 0, 0, 0) }
}
/// Maps raw area-coverage to the opacity Photoshop's anti-alias modes imply. Exponents above 1 thin
/// the fringe (sharper); below 1 widen it (stronger smoothing).
@@ -277,8 +311,14 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
}
let n = format.mode.color_channels();
let stride = n + 1;
let bold = layout.glyphs.iter().any(|g| is_bold(layout, g));
if !fits_raster_budget(w, h, stride, format.sample, bold, MAX_RASTER_BYTES) {
return nothing(format);
}
// Premultiplied accumulation.
let mut acc = vec![0.0f32; w * h * stride];
let Some(mut acc) = zeroed(w * h * stride) else {
return nothing(format);
};
let xf = Xform([1.0, 0.0, 0.0, 1.0, -rect.x0 as f64, -rect.y0 as f64]).mul(&Xform(transform.m));
let mut colors: Vec<Color> = Vec::new();
for st in &layout.styles {
@@ -287,14 +327,23 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
}
}
for c in &colors {
let mut cov = Coverage::new(w, h);
let Some(mut cov) = Coverage::try_new(w, h) else {
return nothing(format);
};
draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain);
let mut cov = cov.finish();
if layout.glyphs.iter().any(|g| is_bold(layout, g)) {
let Some(mut cov) = cov.finish() else {
return nothing(format);
};
if bold {
for k in 0..BOLD_OFFSETS.len() {
let mut bc = Coverage::new(w, h);
let Some(mut bc) = Coverage::try_new(w, h) else {
return nothing(format);
};
draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k));
for (a, b) in cov.iter_mut().zip(bc.finish()) {
let Some(bold_cov) = bc.finish() else {
return nothing(format);
};
for (a, b) in cov.iter_mut().zip(bold_cov) {
*a = a.max(b);
}
}
@@ -445,7 +494,8 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) ->
#[cfg(test)]
mod tests {
use super::{MAX_PIXELS, rect_from_bounds};
use super::{MAX_PIXELS, fits_raster_budget, rect_from_bounds, zeroed};
use photocraft_color::SampleType;
use photocraft_geom::Rect;
#[test]
@@ -466,4 +516,25 @@ mod tests {
assert_eq!(rect_from_bounds(bounds), first, "bounds: {bounds:?}");
}
}
/// #4979: the pixel cap says nothing about bytes, so the peak is admitted from the channel
/// depth of the accumulator and surface, the sample size and the faux-bold coverage buffers.
/// A 100 x 100 grey+alpha render at 8-bit keeps 2 x (4 + 1) + 2 x 4 = 18 bytes a pixel live.
#[test]
fn the_byte_budget_follows_channels_depth_and_faux_bold() {
let px = 100 * 100;
assert!(fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 18));
assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 18 - 1), "the allowance is exact");
// The same pixels cost more with more channels, a wider sample, or a bold pass.
assert!(!fits_raster_budget(100, 100, 5, SampleType::U8, false, px * 18), "CMYK is deeper");
assert!(!fits_raster_budget(100, 100, 2, SampleType::F32, false, px * 18), "f32 output is wider");
assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 18), "bold holds one more buffer");
assert!(fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 22), "2 x 5 + 3 x 4 bytes a pixel");
// A size whose peak overflows is refused rather than wrapping to a small number.
assert!(!fits_raster_budget(usize::MAX, usize::MAX, 5, SampleType::F32, true, u64::MAX));
// Nothing is reserved for a buffer that cannot be served.
assert!(zeroed(usize::MAX).is_none());
}
}
+28 -4
View File
@@ -1728,10 +1728,17 @@ fn craft_fonts_cover_japanese_without_system_fonts() {
let l = e.layout(&point(text, 24.0), 72.0);
assert_eq!(l.glyphs.len(), text.chars().count());
assert!(l.glyphs.iter().all(|g| g.id != 0), "no .notdef with craft-fonts");
// Sans (Inter) runs fall back to the Gothic UI family.
assert_eq!(craft_family_of(&l), vec![crate::craft_fonts::UI_JAPANESE_FAMILY]);
// Serif runs fall back to a Mincho face when the build has one (not the web build).
if crate::CRAFT_FONTS.iter().any(|f| f.is_mincho()) {
// Sans (Inter) runs fall back to the first CJK craft family in the UI locale's script order:
// the Gothic UI family when Japanese leads that order or the build has no other CJK craft font.
let cjk = |f: &crate::CraftFont| f.scripts.iter().any(|s| matches!(*s, "Jpan" | "Hans" | "Hant" | "Kore"));
let first = e.fonts.fallback_stack().find_map(|fam| crate::CRAFT_FONTS.iter().find(|f| f.family == fam && cjk(f))).map(|f| f.family);
if !crate::CRAFT_FONTS.iter().any(|f| cjk(f) && !f.is_japanese()) || crate::cjk::ui_script_order()[0] == crate::cjk::CjkScript::Japanese {
assert_eq!(first, Some(crate::craft_fonts::UI_JAPANESE_FAMILY));
}
assert_eq!(craft_family_of(&l), first.into_iter().collect::<Vec<_>>());
// Serif runs fall back to a Mincho face when the build has one (not the web build) and the
// Japanese craft fonts come first.
if first == Some(crate::craft_fonts::UI_JAPANESE_FAMILY) && crate::CRAFT_FONTS.iter().any(|f| f.is_mincho()) {
let mut t = point(text, 24.0);
t.font_family = "Times New Roman".into();
let l = e.layout(&t, 72.0);
@@ -1744,6 +1751,23 @@ fn craft_fonts_cover_japanese_without_system_fonts() {
assert!(craft_family_of(&l).is_empty());
}
/// Chinese, Arabic and Thai set in Inter fall back to the craft font for their script when the
/// build has one (Noto Sans CJK SC, Noto Sans Arabic and Noto Sans Thai in newer craft-fonts).
#[test]
fn craft_fonts_cover_their_other_scripts_without_system_fonts() {
let mut e = TextEngine::new();
for (script, text) in [("Hans", "中文图层"), ("Arab", "مرحبا"), ("Thai", "ภาษาไทย")] {
if !crate::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&script)) {
eprintln!("skipping {script}: built without a craft font for it (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
continue;
}
let l = e.layout(&point(text, 24.0), 72.0);
assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{script}: no .notdef with craft-fonts");
let fams = craft_family_of(&l);
assert!(fams.iter().any(|fam| crate::CRAFT_FONTS.iter().any(|f| f.family == *fam && f.scripts.contains(&script))), "{script}: drawn with {fams:?}");
}
}
#[test]
fn works_without_craft_fonts() {
// Whatever the build: the bundled fonts load, Latin lays out, and Japanese never panics
+35
View File
@@ -110,6 +110,13 @@ impl Warp {
}
}
/// The arc length along `path`, measured as type on it measures it (from the end it starts at
/// with `flip`), of the point on the path nearest (`x`, `y`): the `start` that puts the type's
/// origin where the Type tool clicked on the path. `None` for a path without length.
pub fn arc_length_at(path: &Subpath, flip: bool, x: f64, y: f64) -> Option<f64> {
PathFrame::new(path, 0.0, flip).map(|f| f.project(x, y))
}
/// Most cubic segments of a type path that are followed (a hostile file cannot make this large).
const MAX_PATH_SEGMENTS: usize = 4096;
@@ -163,6 +170,22 @@ impl PathFrame {
self.cum.last().copied().unwrap_or(0.0)
}
/// Arc length of the point on the flattened path nearest (`x`, `y`).
fn project(&self, x: f64, y: f64) -> f64 {
let mut best = (f64::INFINITY, 0.0);
for (i, w) in self.pts.windows(2).enumerate() {
let [a, b] = [w[0], w[1]];
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((x - a.0) * dx + (y - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
let d = (a.0 + dx * t - x).hypot(a.1 + dy * t - y);
if d < best.0 {
best = (d, self.cum.get(i).copied().unwrap_or(0.0) + len2.sqrt() * t);
}
}
best.1
}
/// Point and unit tangent at arc length `s`; past either end the end tangent continues straight.
fn at(&self, s: f64) -> ((f64, f64), (f64, f64)) {
let last = self.pts.len() - 1;
@@ -211,6 +234,18 @@ mod tests {
}
}
/// The Type tool's click on a path: the nearest point's arc length, from the other end with flip.
#[test]
fn arc_length_at_projects_onto_the_path() {
let line = Subpath::polyline(&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)]);
let at = |flip, x, y| arc_length_at(&line, flip, x, y).unwrap();
assert!((at(false, 30.0, -8.0) - 30.0).abs() < 1e-6);
assert!((at(false, 110.0, 20.0) - 120.0).abs() < 1e-6, "on the second segment");
assert!((at(true, 30.0, -8.0) - 120.0).abs() < 1e-6, "flipped: measured from the far end");
assert!(at(false, -50.0, 0.0).abs() < 1e-6, "clamped to the start");
assert!(arc_length_at(&Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), false, 0.0, 0.0).is_none());
}
#[test]
fn styles_resolve_and_none_is_identity() {
assert_eq!(psd_style("Arc"), Some("warpArc"));
+10 -1
View File
@@ -4412,6 +4412,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// Photoshop's Move tool keeps the standard arrow pointer (#5646): the movable
// layer under it is marked by the hover outline, not by a different cursor.
Tool::Move => egui::CursorIcon::Default,
// Over a path it can follow, the Type tool's path-type pointer (Photoshop).
Tool::Type
if app.drag.is_none() && {
let [x, y] = xf.to_doc(p);
crate::type_tool::path_at(app, x, y).is_some()
} =>
{
crate::type_tool::path_cursor(&painter, p)
}
// Dragging is handled with the pan itself, above.
Tool::Hand => crate::tool_cursor::hand(ui.ctx(), false),
Tool::RotateView => crate::rotate_view::cursor(response.dragged()),
@@ -5453,7 +5462,7 @@ pub(crate) fn composite_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option
fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui::Modifiers) {
let tool = app.active_tool();
if tool.is_type() && app.drag.is_none() {
crate::type_tool::pointer_move(app, x, y);
crate::type_tool::pointer_move(app, x, y, mods);
}
if tool == Tool::Pen && mods.alt {
crate::vector_ui::pen_break_handle(app);
+6
View File
@@ -1804,6 +1804,12 @@ mod tests {
assert!(missing.is_empty(), "missing Spanish dynamic labels: {missing:#?}");
}
#[test]
fn russian_covers_generated_filter_options_and_gallery_names() {
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ru-RU").unwrap());
assert!(missing.is_empty(), "missing Russian dynamic labels: {missing:#?}");
}
/// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their
/// params, choices and title in every UI language, not just Korean.
#[test]
@@ -1,6 +1,6 @@
PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech,
Turkish, Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0,
at your option.
Turkish, Hungarian, Serbian and Russian UI translation catalogs are licensed under MIT OR
Apache-2.0, at your option.
The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE.
Korean translations: PhotoCraft contributors, original translations of
@@ -37,3 +37,6 @@ PhotoCraft's English labels; no proprietary translation resources were used.
Serbian translations: PhotoCraft contributors, original translations of
PhotoCraft's English labels; no proprietary translation resources were used.
Russian translations: PhotoCraft contributors, original translations of
PhotoCraft's English labels; no proprietary translation resources were used.
+144 -6
View File
@@ -1499,6 +1499,7 @@
Set shape fill type Nastavit typ výplně tvaru
Set shape stroke type Nastavit typ tahu tvaru
Set the text color Nastavit barvu textu
Create warped text Vytvořit deformovaný text
Shadows Stíny
Shape: Tvar:
Sharpening Doostření
@@ -2382,11 +2383,11 @@ cameraRaw Color Barvy
Apply At Canvas Bounds Použít efekt na hranicích plátna
# Shared screen eyedropper and text Color Picker (#1128).
Color Picker (Text Color) Výběr barvy (Barva textu)
Color Picker (Text Color) Výběr barvy (barva textu)
Color Picker ({name}) Výběr barvy ({name})
Color Picker (Fill Color) Výběr barvy (Barva výplně)
Color Picker (Fill Color) Výběr barvy (barva výplně)
Fill color Barva výplně
Color Picker (Stroke Color) Výběr barvy (Barva tahu)
Color Picker (Stroke Color) Výběr barvy (barva tahu)
Stroke color Barva tahu
Pick screen color Vybrat barvu obrazovky
Sample a pixel anywhere on the screen Vzorkovat pixel kdekoli na obrazovce
@@ -2538,7 +2539,7 @@ fontWeight Italic Kurzíva
Match Canvas Podle plátna
Auto Adjust Opacity Automaticky upravit krytí
Crop Shield color Barva stínění oříznutí
Color Picker (Crop Shield Color) Výběr barvy (Barva stínění oříznutí)
Color Picker (Crop Shield Color) Výběr barvy (barva stínění oříznutí)
From Center Ze středu
Link radii Propojit poloměry
Star Ratio: Poměr hvězdy:
@@ -2655,7 +2656,7 @@ cameraData Lens Objektiv
Search settings… Hledat nastavení…
No matching settings Žádná odpovídající nastavení
# Edit › Content-Aware Fill workspace
Sampling Brush Tool Nástroj Štětec vzorkování
Sampling Brush Tool Nástroj štětec vzorkování
Sampling Area Overlay Překrytí oblasti vzorkování
Show Sampling Area Zobrazit oblast vzorkování
Indicates Označuje
@@ -3832,7 +3833,7 @@ navigator Zoom Zvětšení
Use the embedded profile ({profile}) Použít vložený profil ({profile})
Convert to the working space ({profile}) Převést do pracovního prostoru barev ({profile})
Remember this choice for future images Zapamatovat si tuto volbu pro další obrázky
Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Dotaz znovu zapnete volbou Ask On Mismatch v nabídce Úpravy › Nastavení barev.
Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Dotaz znovu zapnete volbou „Ptát se při nesouladu profilů“ v nabídce Úpravy › Nastavení barev.
Button Mode Režim tlačítka
Button Mode On Režim tlačítka zapnutý
@@ -3920,6 +3921,138 @@ navigator Zoom Zvětšení
The fastest and the softest, with colour fringing on fine detail. Nejrychlejší a nejměkčí, s barevnými lemy v jemných detailech.
“{name}” already exists. Replace it? „{name}“ již existuje. Nahradit?
The name in the save dialog was “{name}”. Název v dialogu ukládání byl „{name}“.
# Labels the UI looks up through a variable (tool, glyph-group, Liquify and warp names, command
# parameters and choices humanized from their keys, history steps and contextual
# Camera Raw / layer labels) that other catalogs already covered. Translated the same way as
# above, from the plain meaning of the English label.
Export As: {name} Exportovat jako: {name}
Tool Nástroj
Arc Lower Oblouk dole
Arc Upper Oblouk nahoře
Arrows Šipky
Basic Latin Základní latinka
Bloat Nafouknout
Burn Ztmavení
Checkerboard Šachovnice
Clone Stamp Klonovací razítko
Color Sampler Tool Nástroj vzorkovač barev
Currency Měnové symboly
Cyrillic Cyrilice
Decrease Type Size Zmenšit velikost písma
Decrease Type Size by 10 Zmenšit velikost písma o 10
Delete Snapshot Odstranit snímek
Detect Faces Rozpoznat obličeje
Dodge Zesvětlení
Erase with Brush or Pencil (hold) Mazat štětcem nebo tužkou (při podržení klávesy)
Fill with Background Color Vyplnit barvou pozadí
Fill with Background Color, Preserve Transparency Vyplnit barvou pozadí, zachovat průhlednost
Fill with Foreground Color Vyplnit barvou popředí
Fill with Foreground Color, Preserve Transparency Vyplnit barvou popředí, zachovat průhlednost
Forward Warp Deformovat tažením
Freeze Lasso Laso zmrazení
Freeze Mask Zmrazit oblast
Greek Řecké písmo
Hand Tool (hold) Nástroj ruka (při podržení klávesy)
History Brush Štětec historie
Increase Type Size Zvětšit velikost písma
Increase Type Size by 10 Zvětšit velikost písma o 10
Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kerning: zvolte „Metrické“, „Optické“ nebo hodnotu v 1/1000 em (Alt+←/→ v místě textového kurzoru)
Latin 1 Latinka 1
Latin Extended-A Rozšířená latinka A
Latin Extended-B Rozšířená latinka B
Lock Transparent Pixels Zamknout průhledné pixely
Make Selection from Path Vytvořit výběr z cesty
Mask None Zrušit maskování
Math Symbols Matematické symboly
New Snapshot Vytvořit snímek
Numbers Čísla
Pattern Fill… Výplň vzorkem…
Pick Color From Image Vybrat barvu z obrazu
Proof Setup… Nastavení nátisku…
Pucker Stáhnout
Push Left Tlačit doleva
Reconstruct All Rekonstruovat vše
Save Path Uložit cestu
Sponge Houba
Target Channel 3 Zacílit na kanál 3
Target Channel 4 Zacílit na kanál 4
Target Channel 5 Zacílit na kanál 5
Target Channel 6 Zacílit na kanál 6
Target Channel 7 Zacílit na kanál 7
Target Channel 8 Zacílit na kanál 8
Target Channel 9 Zacílit na kanál 9
Target Composite Channel Zacílit na složený kanál
Temporary Dočasné
Thaw Mask Rozmrazit oblast
Toggle Layer Mask Overlay Přepnout překryv masky vrstvy
Twirl Clockwise Zvířit doprava
Twirl Counterclockwise Zvířit doleva
Warming Filter (85) Oteplovací filtr (85)
Zoom In (hold, drag to scrub) Přiblížit (při podržení klávesy, tažením plynule měníte zvětšení)
Zoom Out (hold) Oddálit (při podržení klávesy)
blue modrá
gray šedá
green zelená
none žádná
orange oranžová
red červená
violet fialová
yellow žlutá
Adobe-rgb-compat Kompatibilní s Adobe RGB (1998)
Any Bez omezení
Ask On Mismatch Ptát se při nesouladu profilů
Ask On Missing Ptát se při chybějícím profilu
Ask On Paste Ptát se při vkládání
Author Title Pracovní pozice autora
Based On Podle
Blend Text Gamma Gama prolnutí textu
Case Sensitive Rozlišovat velikost písmen
Ccw Proti směru hodinových ručiček
Change Find Změnit a najít další
Cmyk CMYK
Coated-cmyk CMYK pro křídový papír
Convert Převést
Copyright Url URL informací o autorských právech
Cw Po směru hodinových ručiček
Display-p3 Display P3
Dont Enlarge Nezvětšovat
Forward Vpřed
Horizontal Distortion Vodorovné zkreslení
Intent Záměr převodu
Linear-srgb Lineární sRGB
Mask Channel Kanál masky
Monitor Profile Profil monitoru
Nearest Nejbližší soused
Policy Cmyk Zásady správy barev pro CMYK
Policy Gray Zásady správy barev pro šedou
Policy Rgb Zásady správy barev pro RGB
Prophoto-compat Kompatibilní s ProPhoto
R G B RGB
Rec2020 Rec. 2020
Sgray sGray (tónová křivka sRGB)
Simulate Paper Simulovat papír
Source Channel Zdrojový kanál
Source Invert Invertovat zdroj
Source1 Channel Kanál zdroje 1
Source1 Invert Invertovat zdroj 1
Source2 Channel Kanál zdroje 2
Source2 Invert Invertovat zdroj 2
Title Název
Vertical Distortion Svislé zkreslení
Whole Word Celé slovo
Working Cmyk Pracovní CMYK
Working Gray Pracovní šedá
Working Rgb Pracovní RGB
Working-cmyk Pracovní CMYK
Warming Filter (LBA) Oteplovací filtr (LBA)
Warming Filter (81) Oteplovací filtr (81)
Cooling Filter (80) Ochlazovací filtr (80)
Cooling Filter (LBB) Ochlazovací filtr (LBB)
Cooling Filter (82) Ochlazovací filtr (82)
layers Fill Krytí výplně
layers Fill: Krytí výplně:
cameraRaw Dehaze Odstranění oparu
Memory Usage Využití paměti
Per-document RAM limit (MB) Limit RAM na dokument (MB)
Installed RAM: {ram} MB; this limit: {percent}% Nainstalovaná RAM: {ram} MB; tento limit: {percent}%
@@ -3950,3 +4083,8 @@ navigator Zoom Zvětšení
Resize Windows to Fit Přizpůsobit velikost oken
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Automatický směr odstavce
Left-to-right paragraph direction Směr odstavce zleva doprava
Right-to-left paragraph direction Směr odstavce zprava doleva
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -1482,6 +1482,7 @@
Set shape fill type Fülltyp der Form festlegen
Set shape stroke type Konturtyp der Form festlegen
Set the text color Textfarbe festlegen
Create warped text Verkrümmten Text erstellen
Shadows Tiefen
Shape: Form:
Sharpening Scharfzeichnung
@@ -3348,3 +3349,8 @@ curves Smooth Glätten
Resize Windows to Fit Fenstergröße anpassen
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Automatische Absatzrichtung
Left-to-right paragraph direction Absatzrichtung von links nach rechts
Right-to-left paragraph direction Absatzrichtung von rechts nach links
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -3046,6 +3046,7 @@
Set shape fill type Ορισμός τύπου γεμίσματος σχήματος
Set shape stroke type Ορισμός τύπου περιγράμματος σχήματος
Set the text color Ορισμός χρώματος κειμένου
Create warped text Δημιουργία παραμορφωμένου κειμένου
Shadows/Highlights Σκιές/φωτεινές περιοχές
Shape Σχήμα
Shape Blur Θόλωση σχήματος
@@ -4038,3 +4039,8 @@ curves Smooth Εξομάλυνση
Resize Windows to Fit Προσαρμογή μεγέθους παραθύρων
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Αυτόματη κατεύθυνση παραγράφου
Left-to-right paragraph direction Κατεύθυνση παραγράφου από αριστερά προς τα δεξιά
Right-to-left paragraph direction Κατεύθυνση παραγράφου από δεξιά προς τα αριστερά
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+6
View File
@@ -1489,6 +1489,7 @@
Set shape fill type Establecer el tipo de relleno de la forma
Set shape stroke type Establecer el tipo de trazo de la forma
Set the text color Establecer el color del texto
Create warped text Crear texto deformado
Shadows Sombras
Shape: Forma:
Sharpening Enfoque
@@ -4300,3 +4301,8 @@ navigator Zoom Zoom
Resize Windows to Fit Redimensionar ventanas para ajustar
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Dirección de párrafo automática
Left-to-right paragraph direction Dirección de párrafo de izquierda a derecha
Right-to-left paragraph direction Dirección de párrafo de derecha a izquierda
Can't render this file because it contains an unexpected character in line 4 and column 47.
File diff suppressed because it is too large Load Diff
+491
View File
@@ -0,0 +1,491 @@
# Hindi (hi) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
#
# context empty a plain UI string; `source` is the English text in the code
# @id a command id (e.g. file.saveAs): wins over the English text for
# that menu item, so one id can read differently from its twin
# @plural `source` is "one|other" (English forms); the translation lists the
# language's forms separated by "|" (see `plural` in i18n/mod.rs)
# anything else a disambiguating context passed to `tr_ctx`
# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns.
# lines starting with # and blank lines are ignored.
#
# Clean-room: every entry was written from the plain meaning of the English label using ordinary
# Hindi computing and image-editing vocabulary. No proprietary localisation resource, string
# table or product UI was consulted or copied; add entries the same way. Product and technology
# names (PhotoCraft, ArtCraft, OpenType, RGB, CMYK, Lab, PSD, PNG, JPEG, WebP, OpenEXR, AVIF …)
# stay as they are; the trailing "…" of dialog commands is kept. Plural rule: one / other
# (`plural_one_other` in i18n/mod.rs).
# Top-level Menus
File फ़ाइल
Edit एडिट
Image इमेज
Layer लेयर
Type टाइप
Select चयन
Filter फ़िल्टर
3D 3डी
View व्यू
Window विंडो
Help सहायता
# File Menu
New… नया…
Open… खोलें…
Open Recent हाल ही में खोले गए
Close बंद करें
Close All सभी बंद करें
Close Others अन्य सभी बंद करें
Save सहेजें
Save As… इस रूप में सहेजें…
Save a Copy… एक प्रति सहेजें…
Revert मूल स्थिति में लाएं
Export एक्सपोर्ट करें
Export As… इस रूप में एक्सपोर्ट करें…
Quick Export as PNG PNG के रूप में त्वरित एक्सपोर्ट
Import इम्पोर्ट करें
File Info… फ़ाइल जानकारी…
Print… प्रिंट करें…
Print One Copy एक प्रति प्रिंट करें
Exit बाहर निकलें
# Edit Menu
Undo पूर्ववत करें
Redo फिर से करें
Cut काटें
Copy कॉपी करें
Copy Merged मर्ज की गई कॉपी
Paste पेस्ट करें
Paste Special विशेष रूप में पेस्ट करें
Paste in Place स्थान पर पेस्ट करें
Paste Into अंदर पेस्ट करें
Paste Outside बाहर पेस्ट करें
Clear साफ़ करें
Fill… भरें…
Stroke… स्ट्रोक…
Free Transform मुक्त रूपांतरण
Transform रूपांतरण करें
Scale आकार बदलें
Rotate घुमाएँ
Skew तिरछा करें
Distort विरूपित करें
Perspective परिप्रेक्ष्य
Warp मोड़ें
Flip Horizontal क्षैतिज पलटें
Flip Vertical लंबवत पलटें
Preferences प्राथमिकताएं
General… सामान्य…
Interface… इंटरफ़ेस…
Workspace… कार्यस्थान…
Tools… टूल्स…
Performance… परफॉरमेंस…
# Image Menu
Mode मोड
RGB Color RGB रंग
CMYK Color CMYK रंग
Grayscale ग्रेस्केल
Lab Color Lab रंग
8 Bits/Channel 8 बिट्स/चैनल
16 Bits/Channel 16 बिट्स/चैनल
32 Bits/Channel 32 बिट्स/चैनल
Adjustments समायोजन
Brightness/Contrast… चमक/कंट्रास्ट…
Levels… स्तर…
Curves… कर्व्स…
Exposure… एक्सपोज़र…
Vibrance… वाइब्रेंस…
Hue/Saturation… ह्यू/सैचुरेशन…
Color Balance… कलर बैलेंस…
Black & White… ब्लैक एंड व्हाइट…
Photo Filter… फ़ोटो फ़िल्टर…
Invert इन्वर्ट
Posterize… पोस्टराइज़ करें…
Threshold… थ्रेशहोल्ड…
Image Size… इमेज साइज़…
Canvas Size… कैनवास साइज़…
Image Rotation इमेज रोटेशन
180° 180°
90° Clockwise 90° दक्षिणावर्त
90° Counter Clockwise 90° वामावर्त
Arbitrary… इच्छानुसार…
Crop क्रॉप करें
Trim… ट्रिम करें…
Reveal All सभी दिखाएँ
Duplicate… डुप्लीकेट…
# Layer Menu
New नया
Layer… लेयर…
Layer from Background बैकग्राउंड से लेयर
Group… समूह…
Group from Layers… लेयर्स से समूह…
Artboard… आर्टबोर्ड…
Duplicate Layer… लेयर को डुप्लीकेट करें…
Delete हटाएँ
Delete Layer लेयर हटाएँ
Delete Hidden Layers छिपी हुई लेयर्स हटाएँ
Rename Layer… लेयर का नाम बदलें…
Layer Style लेयर स्टाइल
Blending Options… ब्लेंडिंग विकल्प…
Smart Objects स्मार्ट ऑब्जेक्ट्स
Convert to Smart Object स्मार्ट ऑब्जेक्ट में बदलें
Layer Mask लेयर मास्क
Hide All सभी छिपाएँ
Reveal Selection चयन दिखाएँ
Hide Selection चयन छिपाएँ
From Transparency पारदर्शिता से
Apply लागू करें
Enable सक्षम करें
Disable अक्षम करें
Merge Down नीचे मर्ज करें
Merge Visible दृश्यमान मर्ज करें
Flatten Image इमेज फ़्लैट करें
# Select Menu
All सभी
Deselect अचयनित करें
Reselect पुनः चयन करें
Inverse उल्टा करें
All Layers सभी लेयर्स
Deselect Layers लेयर्स अचयनित करें
Find Layers लेयर्स खोजें
Color Range… रंग सीमा…
Select and Mask… चयन और मास्क…
Modify संशोधित करें
Border… सीमा…
Smooth… चिकना करें…
Expand… विस्तार करें…
Contract… संकुचित करें…
Feather… फ़ेदर…
Grow बढ़ाएँ
Similar समान
Transform Selection चयन रूपांतरित करें
Load Selection… चयन लोड करें…
Save Selection… चयन सहेजें…
# View Menu
Proof Setup प्रूफ़ सेटअप
Proof Colors प्रूफ़ रंग
Zoom In ज़ूम इन
Zoom Out ज़ूम आउट
Fit on Screen स्क्रीन पर फ़िट करें
Fit Layer on Screen स्क्रीन पर लेयर फ़िट करें
Actual Pixels वास्तविक पिक्सेल
100% 100%
200% 200%
Rulers रूलर्स
Snap स्नैप
Snap To पर स्नैप करें
Guides गाइड्स
Lock Guides गाइड्स लॉक करें
Clear Guides गाइड्स हटाएं
New Guide… नई गाइड…
Lock Slices स्लाइस लॉक करें
Clear Slices स्लाइस हटाएं
# Window Menu & Panels
Arrange व्यवस्थित करें
Tile Horizontally क्षैतिज टाइल
Tile Vertically लंबवत टाइल
Consolidate All to Tabs सभी को टैब में समेकित करें
Workspace कार्यस्थान
Reset Essentials एसेंशियल्स रीसेट करें
New Workspace… नया कार्यस्थान…
Delete Workspace… कार्यस्थान हटाएँ…
Lock Workspace कार्यस्थान लॉक करें
Color रंग
Swatches स्वॉचेस
Gradients ग्रेडिएंट्स
Patterns पैटर्न्स
Properties गुण
Layers लेयर्स
Channels चैनल्स
Paths पाथ्स
History हिस्ट्री
Actions एक्शन्स
Brushes ब्रश
Brush Settings ब्रश सेटिंग्स
Navigator नेविगेटर
Info जानकारी
Timeline टाइमलाइन
Tools टूल्स
Options विकल्प
Status Bar स्टेटस बार
# Tools (Toolbox)
Move Tool मूव टूल
Artboard Tool आर्टबोर्ड टूल
Rectangular Marquee Tool रेक्टेंगुलर मार्की टूल
Elliptical Marquee Tool एलिप्टिकल मार्की टूल
Single Row Marquee Tool सिंगल रो मार्की टूल
Single Column Marquee Tool सिंगल कॉलम मार्की टूल
Lasso Tool लैस्सो टूल
Polygonal Lasso Tool पॉलीगोनल लैस्सो टूल
Magnetic Lasso Tool मैग्नेटिक लैस्सो टूल
Object Selection Tool ऑब्जेक्ट चयन टूल
Quick Selection Tool क्विक चयन टूल
Magic Wand Tool मैजिक वैंड टूल
Crop Tool क्रॉप टूल
Perspective Crop Tool परस्पेक्टिव क्रॉप टूल
Slice Tool स्लाइस टूल
Slice Select Tool स्लाइस चयन टूल
Frame Tool फ़्रेम टूल
Eyedropper Tool आईड्रॉपर टूल
Color Sampler Tool कलर सैंपलर टूल
Ruler Tool रूलर टूल
Note Tool नोट टूल
Count Tool काउंट टूल
Spot Healing Brush Tool स्पॉट हीलिंग ब्रश टूल
Healing Brush Tool हीलिंग ब्रश टूल
Patch Tool पैच टूल
Content-Aware Move Tool कंटेंट-अवेयर मूव टूल
Red Eye Tool रेड आई टूल
Brush Tool ब्रश टूल
Pencil Tool पेंसिल टूल
Color Replacement Tool कलर रिप्लेसमेंट टूल
Mixer Brush Tool मिक्सर ब्रश टूल
Clone Stamp Tool क्लोन स्टैम्प टूल
Pattern Stamp Tool पैटर्न स्टैम्प टूल
History Brush Tool हिस्ट्री ब्रश टूल
Art History Brush Tool आर्ट हिस्ट्री ब्रश टूल
Eraser Tool इरेज़र टूल
Background Eraser Tool बैकग्राउंड इरेज़र टूल
Magic Eraser Tool मैजिक इरेज़र टूल
Gradient Tool ग्रेडिएंट टूल
Paint Bucket Tool पेंट बकेट टूल
Blur Tool ब्लर टूल
Sharpen Tool शार्पन टूल
Smudge Tool स्मज टूल
Dodge Tool डॉज टूल
Burn Tool बर्न टूल
Sponge Tool स्पंज टूल
Pen Tool पेन टूल
Freeform Pen Tool फ्रीफॉर्म पेन टूल
Curvature Pen Tool कर्वचर पेन टूल
Add Anchor Point Tool ऐड एंकर पॉइंट टूल
Delete Anchor Point Tool डिलीट एंकर पॉइंट टूल
Convert Point Tool कन्वर्ट पॉइंट टूल
Horizontal Type Tool हॉरिजोंटल टाइप टूल
Vertical Type Tool वर्टिकल टाइप टूल
Horizontal Type Mask Tool हॉरिजोंटल टाइप मास्क टूल
Vertical Type Mask Tool वर्टिकल टाइप मास्क टूल
Path Selection Tool पाथ चयन टूल
Direct Selection Tool डायरेक्ट चयन टूल
Rectangle Tool रेक्टेंगल टूल
Rounded Rectangle Tool राउंडेड रेक्टेंगल टूल
Ellipse Tool एलिप्स टूल
Triangle Tool ट्रायंगल टूल
Polygon Tool पॉलीगोन टूल
Line Tool लाइन टूल
Custom Shape Tool कस्टम शेप टूल
Hand Tool हैंड टूल
Rotate View Tool रोटेट व्यू टूल
Zoom Tool ज़ूम टूल
# Common Dialogs and Buttons
OK ठीक है
Cancel रद्द करें
Reset रीसेट करें
Done संपन्न
Don't Save सहेजें नहीं
Yes हाँ
No नहीं
Width: चौड़ाई:
Height: ऊंचाई:
Resolution: रिज़ॉल्यूशन:
Pixels पिक्सेल
Inches इंच
Centimeters सेंटीमीटर
Millimeters मिलीमीटर
Points पॉइंट्स
Percent प्रतिशत
Opacity: ओपेसिटी:
Flow: फ्लो:
Hardness: हार्डनेस:
Size: साइज़:
Angle: एंगल:
Roundness: राउंडनेस:
Spacing: स्पेसिंग:
Color: कलर:
Name: नेम:
Presets: प्रीसेट्स:
Language भाषा
Auto स्वचालित
# Plurals
@plural {n} layer|{n} layers {n} लेयर|{n} लेयर्स
@plural {n} item|{n} items {n} आइटम|{n} आइटम्स
@plural {n} document|{n} documents {n} दस्तावेज़|{n} दस्तावेज़
@plural {n} pixel|{n} pixels {n} पिक्सेल|{n} पिक्सेल्स
# Tool and Scope strings
Scope दायरा
Selected चयनित
Mixer Brush मिक्सर ब्रश
Wet गीला
Load लोड
Mix मिश्रण
Flow प्रवाह
Sample All Layers सभी लेयर्स का नमूना लें
Pattern Stamp पैटर्न स्टैम्प
Impressionist प्रभाववादी
Aligned संरेखित
Fit in View दृश्य में फ़िट करें
Camera Raw Camera Raw
# Camera Raw
cameraRaw Amount मात्रा
Amount मात्रा
cameraRaw Aqua एक्वा
Aqua एक्वा
cameraRaw Balance संतुलन
Balance संतुलन
cameraRaw Before (Y) पहले (Y)
Before (Y) पहले (Y)
cameraRaw Blacks ब्लैक्स
Blacks ब्लैक्स
cameraRaw Blending मिश्रण
Blending मिश्रण
cameraRaw Blue नीला
Blue नीला
cameraRaw Camera Raw ({name}) Camera Raw ({name})
cameraRaw Camera Raw Filter ({layer}) Camera Raw फ़िल्टर ({layer})
Camera Raw Filter ({layer}) Camera Raw फ़िल्टर ({layer})
cameraRaw Camera Raw isn't open Camera Raw खुला नहीं है
Camera Raw isn't open Camera Raw खुला नहीं है
cameraRaw Cancel रद्द करें
cameraRaw Cancelled opening {name} {name} खोलना रद्द किया गया
Cancelled opening {name} {name} खोलना रद्द किया गया
cameraRaw Cancelled {label} {label} रद्द किया गया
Cancelled {label} {label} रद्द किया गया
cameraRaw Clarity स्पष्टता
Clarity स्पष्टता
cameraRaw Color रंग
cameraRaw Color Detail रंग विवरण
Color Detail रंग विवरण
cameraRaw Color Grading कलर ग्रेडिंग
Color Grading कलर ग्रेडिंग
cameraRaw Color Mixer कलर मिक्सर
Color Mixer कलर मिक्सर
cameraRaw Color Priority रंग प्राथमिकता
Color Priority रंग प्राथमिकता
cameraRaw Contrast कंट्रास्ट
Contrast कंट्रास्ट
cameraRaw Curve कर्व
Curve कर्व
cameraRaw Darks डार्क्स
Darks डार्क्स
cameraRaw Dehaze डीहेज़
Dehaze डीहेज़
cameraRaw Detail डिटेल
Detail डिटेल
cameraRaw Developing {name} {name} विकसित किया जा रहा है
Developing {name} {name} विकसित किया जा रहा है
cameraRaw Effects इफ़ेक्ट्स
Effects इफ़ेक्ट्स
cameraRaw Exposure एक्सपोज़र
Exposure एक्सपोज़र
cameraRaw Feather फ़ेदर
Feather फ़ेदर
cameraRaw Full-resolution preview unavailable पूर्ण-रिज़ॉल्यूशन पूर्वावलोकन अनुपलब्ध है
Full-resolution preview unavailable पूर्ण-रिज़ॉल्यूशन पूर्वावलोकन अनुपलब्ध है
cameraRaw Global ग्लोबल
Global ग्लोबल
cameraRaw Grain ग्रेन
Grain ग्रेन
cameraRaw Green हरा
Green हरा
cameraRaw Highlight Clipping Warning (O) हाइलाइट क्लिपिंग चेतावनी (O)
Highlight Clipping Warning (O) हाइलाइट क्लिपिंग चेतावनी (O)
cameraRaw Highlight Priority हाइलाइट प्राथमिकता
Highlight Priority हाइलाइट प्राथमिकता
cameraRaw Highlights हाइलाइट्स
Highlights हाइलाइट्स
cameraRaw Hue ह्यू
Hue ह्यू
cameraRaw Light लाइट
Light लाइट
cameraRaw Lights लाइट्स
Lights लाइट्स
cameraRaw Load Settings… सेटिंग्स लोड करें…
Load Settings… सेटिंग्स लोड करें…
cameraRaw Load settings from a Camera Raw .xmp file Camera Raw .xmp फ़ाइल से सेटिंग्स लोड करें
Load settings from a Camera Raw .xmp file Camera Raw .xmp फ़ाइल से सेटिंग्स लोड करें
cameraRaw Loaded Camera Raw settings from {file} {file} से Camera Raw सेटिंग्स लोड की गईं
Loaded Camera Raw settings from {file} {file} से Camera Raw सेटिंग्स लोड की गईं
cameraRaw Luminance लुमिनेंस
Luminance लुमिनेंस
cameraRaw Luminance Detail लुमिनेंस डिटेल
Luminance Detail लुमिनेंस डिटेल
cameraRaw Magenta मैजेंटा
Magenta मैजेंटा
cameraRaw Masking मास्किंग
Masking मास्किंग
cameraRaw Midpoint मिडपॉइंट
Midpoint मिडपॉइंट
cameraRaw Midtones मिडटोन्स
Midtones मिडटोन्स
cameraRaw Noise Reduction नॉइज़ रिडक्शन
Noise Reduction नॉइज़ रिडक्शन
cameraRaw OK ठीक है
cameraRaw Open खोलें
Open खोलें
cameraRaw Opened {name} {name} खोला गया
Opened {name} {name} खोला गया
cameraRaw Orange नारंगी
Orange नारंगी
cameraRaw Paint Overlay पेंट ओवरले
Paint Overlay पेंट ओवरले
cameraRaw Purple बैंगनी
Purple बैंगनी
cameraRaw Radius त्रिज्या
Radius त्रिज्या
cameraRaw Red लाल
Red लाल
cameraRaw Roughness रफनेस
Roughness रफनेस
cameraRaw Roundness राउंडनेस
Roundness राउंडनेस
cameraRaw Saturation सैचुरेशन
Saturation सैचुरेशन
cameraRaw Save Settings… सेटिंग्स सहेजें…
Save Settings… सेटिंग्स सहेजें…
cameraRaw Save these settings as a Camera Raw .xmp file इन सेटिंग्स को Camera Raw .xmp फ़ाइल के रूप में सहेजें
Save these settings as a Camera Raw .xmp file इन सेटिंग्स को Camera Raw .xmp फ़ाइल के रूप में सहेजें
cameraRaw Shadow Clipping Warning (U) शैडो क्लिपिंग चेतावनी (U)
Shadow Clipping Warning (U) शैडो क्लिपिंग चेतावनी (U)
cameraRaw Shadows शेडोज़
Shadows शेडोज़
cameraRaw Sharpening शार्पनिंग
Sharpening शार्पनिंग
cameraRaw Show Lab Color Readouts Lab रंग रीडआउट दिखाएँ
Show Lab Color Readouts Lab रंग रीडआउट दिखाएँ
cameraRaw Show Red at 3 o'clock 3 बजे की स्थिति पर लाल दिखाएँ
Show Red at 3 o'clock 3 बजे की स्थिति पर लाल दिखाएँ
cameraRaw Show Selected Region चयनित क्षेत्र दिखाएँ
Show Selected Region चयनित क्षेत्र दिखाएँ
cameraRaw Show Skin Tone Indicator स्किन टोन संकेतक दिखाएँ
Show Skin Tone Indicator स्किन टोन संकेतक दिखाएँ
cameraRaw Show Vectorscope वेक्टरस्कोप दिखाएँ
Show Vectorscope वेक्टरस्कोप दिखाएँ
cameraRaw Size आकार
Size आकार
cameraRaw Temperature तापमान
Temperature तापमान
cameraRaw Texture टेक्सचर
Texture टेक्सचर
cameraRaw Tint टिंट
Tint टिंट
cameraRaw Tone Histogram टोन हिस्टोग्राम
Tone Histogram टोन हिस्टोग्राम
cameraRaw Vibrance वाइब्रेंस
Vibrance वाइब्रेंस
cameraRaw Vignetting विगनेटिंग
Vignetting विगनेटिंग
cameraRaw White Balance: As Shot व्हाइट बैलेंस: शॉट के अनुसार
White Balance: As Shot व्हाइट बैलेंस: शॉट के अनुसार
cameraRaw Whites व्हाइट्स
Whites व्हाइट्स
cameraRaw Yellow पीला
Yellow पीला
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -1306,6 +1306,7 @@
Set painting symmetry options Festési szimmetria beállításai
Set the overlay options for the Crop tool A Körbevágás eszköz fedvénybeállításai
Set the text color Szövegszín beállítása
Create warped text Torzított szöveg létrehozása
Settings: Beállítások:
Shape Dynamics Alakdinamika
Sharpen Tool Élesítő
@@ -4255,3 +4256,8 @@ cameraRaw Yellow Sárga
Resize Windows to Fit Ablakok átméretezése a képhez
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Automatikus bekezdésirány
Left-to-right paragraph direction Balról jobbra irányuló bekezdés
Right-to-left paragraph direction Jobbról balra irányuló bekezdés
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -1478,6 +1478,7 @@
Set shape fill type Atur jenis isian bentuk
Set shape stroke type Atur jenis stroke bentuk
Set the text color Atur warna teks
Create warped text Buat teks warp
Shadows Bayangan
Shape: Bentuk:
Sharpening Penajaman
@@ -3352,3 +3353,8 @@ curves Smooth Haluskan
Resize Windows to Fit Ubah ukuran jendela agar pas
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Arah paragraf otomatis
Left-to-right paragraph direction Arah paragraf kiri ke kanan
Right-to-left paragraph direction Arah paragraf kanan ke kiri
Can't render this file because it contains an unexpected character in line 4 and column 47.
+7 -1
View File
@@ -1484,6 +1484,7 @@
Set shape fill type Imposta tipo riempimento forma
Set shape stroke type Imposta tipo traccia forma
Set the text color Imposta colore testo
Create warped text Crea testo deformato
Shadows Ombre
Shape: Forma:
Sharpening Contrasto
@@ -1999,7 +2000,7 @@
Same folder Stessa cartella
Save in background Salva in background
Scratch Disks Dischi virtuali
Screen Schermo
Screen Scherma
Screen resolution Risoluzione schermo
Scrubby slider acceleration Accelerazione slider scrubby
Sessions only Solo sessioni
@@ -4455,3 +4456,8 @@ shape Appearance Aspetto
Resize Windows to Fit Ridimensiona finestre per adattare
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Direzione del paragrafo automatica
Left-to-right paragraph direction Direzione del paragrafo da sinistra a destra
Right-to-left paragraph direction Direzione del paragrafo da destra a sinistra
Can't render this file because it contains an unexpected character in line 2388 and column 12.
+6
View File
@@ -1486,6 +1486,7 @@
Set shape fill type シェイプの塗りの種類を設定
Set shape stroke type シェイプの線の種類を設定
Set the text color 文字色を設定
Create warped text ワープテキストを作成
Shadows シャドウ
Shape: シェイプ:
Sharpening シャープ
@@ -4016,3 +4017,8 @@ curves Smooth スムーズ
Resize Windows to Fit ウィンドウサイズを合わせる
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction 段落の方向を自動判別
Left-to-right paragraph direction 左から右への段落方向
Right-to-left paragraph direction 右から左への段落方向
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -1475,6 +1475,7 @@
Set shape fill type 모양 채우기 유형 설정
Set shape stroke type 모양 획 유형 설정
Set the text color 텍스트 색상 설정
Create warped text 뒤틀린 텍스트 만들기
Shadows 어두운 영역
Shape: 모양:
Sharpening 선명 효과
@@ -3546,3 +3547,8 @@ curves Smooth 매끄럽게
Resize Windows to Fit 창 크기 맞추기
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction 자동 단락 방향
Left-to-right paragraph direction 왼쪽에서 오른쪽 단락 방향
Right-to-left paragraph direction 오른쪽에서 왼쪽 단락 방향
Can't render this file because it contains an unexpected character in line 2929 and column 12.
+137 -1
View File
@@ -187,7 +187,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result<String, String> {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 21] = [
pub static LANGUAGES: [LangInfo; 22] = [
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
@@ -220,6 +220,8 @@ pub static LANGUAGES: [LangInfo; 21] = [
LangInfo { code: "tr", name: "Türkçe", source: catalog!("tr"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
// Vietnamese; `vi-VN` resolves here.
LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
// Hindi; `hi`, `hi-IN` locales resolve here.
LangInfo { code: "hi", name: "हिन्दी", source: catalog!("hi"), plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
];
impl LangInfo {
@@ -280,6 +282,16 @@ impl Lang {
LANGUAGES.iter().map(Lang)
}
/// Every registered language in the order a picker lists them: English first (the source
/// language and the fallback), then the rest by their own name with accents folded, so
/// Čeština sits under C and Türkçe under T rather than after Z. Names in other scripts follow
/// the Latin ones in code point order: Greek, Cyrillic, then the CJK languages.
pub fn for_picker() -> Vec<Lang> {
let mut rest: Vec<Lang> = Lang::all().filter(|l| l.code() != "en").collect();
rest.sort_by_cached_key(|l| name_sort_key(l.name()));
std::iter::once(Lang::EN).chain(rest).collect()
}
pub fn name(self) -> &'static str {
self.0.name
}
@@ -299,6 +311,27 @@ impl Lang {
}
}
/// `name` lowercased with the Latin accents that occur in language names folded away, so a
/// byte-wise sort reads alphabetically. Other scripts are left as they are.
fn name_sort_key(name: &str) -> String {
name.chars()
.flat_map(char::to_lowercase)
.map(|c| match c {
'à'..='å' | 'ā' | 'ă' | 'ą' => 'a',
'ç' | 'ć' | 'č' => 'c',
'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e',
'ì'..='ï' | 'ī' => 'i',
'ñ' | 'ń' | 'ň' => 'n',
'ò'..='ö' | 'ø' | 'ő' => 'o',
'ś' | 'š' => 's',
'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u',
'ý' | 'ÿ' => 'y',
'ź' | 'ż' | 'ž' => 'z',
c => c,
})
.collect()
}
/// Candidate language codes for a locale tag, most specific first: `zh_TW.UTF-8` →
/// `zh-tw`, `zh-hant`, `zh`.
fn candidates(tag: &str) -> Vec<String> {
@@ -436,6 +469,21 @@ mod tests {
const CS: fn() -> Lang = || Lang::from_code("cs").expect("cs registered");
const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered");
const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered");
const HI: fn() -> Lang = || Lang::from_code("hi").expect("hi registered");
#[test]
fn hindi_tags_resolve() {
for tag in ["hi", "hi-IN", "hi_IN", "hi_IN.UTF-8"] {
assert_eq!(lang_from_tag(tag), Some(HI()), "{tag}");
}
assert_eq!(HI().name(), "हिन्दी");
assert_eq!(Lang::from_pref("hi"), HI());
assert_eq!(Lang::from_pref("HI"), HI());
assert_eq!(tr(HI(), "Layer"), "लेयर");
assert_eq!(tr(HI(), "File"), "फ़ाइल");
assert_eq!(trn(HI(), 1, "{n} layer", "{n} layers"), "1 लेयर");
assert_eq!(trn(HI(), 3, "{n} layer", "{n} layers"), "3 लेयर्स");
}
#[test]
fn indonesian_tags_resolve() {
@@ -456,6 +504,22 @@ mod tests {
assert_eq!(Lang::from_pref("VI"), VI());
}
#[test]
fn picker_lists_english_first_then_names_alphabetically() {
let order = Lang::for_picker();
assert_eq!(order.first().map(|l| l.code()), Some("en"));
assert_eq!(order.len(), Lang::all().count());
let keys: Vec<String> = order[1..].iter().map(|l| name_sort_key(l.name())).collect();
assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}");
let pos = |code: &str| order.iter().position(|l| l.code() == code).unwrap_or_else(|| panic!("{code} registered"));
assert!(pos("cs") < pos("de"), "Čeština sorts under C, not after Z");
assert!(pos("it") < pos("hu"), "Magyar sorts under M, not under its code");
assert!(pos("tr") < pos("el"), "Latin names come before Greek");
assert!(pos("uk") < pos("ja"), "Cyrillic names come before CJK");
assert_eq!(name_sort_key("Tiếng Việt"), "tieng viet");
assert_eq!(name_sort_key("Türkçe"), "turkce");
}
#[test]
fn tags_map_to_languages() {
assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA()));
@@ -1174,6 +1238,60 @@ mod tests {
}
}
/// Russian dynamic labels added in the fourth pass (filter parameters, Camera Raw panels,
/// hardware status, rasterize prompts, History/paths menus) render in Russian, not English.
#[test]
fn russian_dynamic_status_hardware_and_dialog_labels_are_translated() {
let ru = Lang::from_code("ru").expect("ru registered");
for kind in [
crate::rasterize_prompt::Kind::Type,
crate::rasterize_prompt::Kind::Shape,
crate::rasterize_prompt::Kind::SmartObject,
crate::rasterize_prompt::Kind::Fill,
] {
assert_ne!(tr(ru, kind.message()), kind.message(), "{:?}", kind);
}
assert_eq!(fmt(tr(ru, "Driver: {driver}"), &[("driver", "Mesa 25.0")]), "Драйвер: Mesa 25.0");
assert_eq!(
fmt(tr(ru, "Monitor profile on {display}: {profile}"), &[("display", "DELL U2720Q"), ("profile", "sRGB IEC61966-2.1")]),
"Профиль монитора для DELL U2720Q: sRGB IEC61966-2.1"
);
assert_eq!(fmt(tr(ru, "Platform: {os} {arch}"), &[("os", "Linux"), ("arch", "x86_64")]), "Платформа: Linux x86_64");
assert_eq!(tr(ru, "Graphics adapter"), "Графический адаптер");
assert_eq!(tr(ru, "Image compositor: {processor}"), "Компоновщик изображения: {processor}");
// Filter / adjustment parameter names.
for label in [
"Amount",
"Radius",
"Threshold",
"Angle",
"High Pass",
"Smart Blur",
"Reduce Noise",
"Lens Correction",
"Colored Pencil",
"Angled Strokes",
"Stained Glass",
"Sumi-e",
"Film Grain",
"Unsharp Mask",
"Lens Flare",
] {
assert!(has(ru, label), "Russian filter label missing: {label}");
}
// Dynamic composed status templates.
assert_eq!(
fmt(tr(ru, "Step {step} ({id}) failed: {error}"), &[("step", "2"), ("id", "file.export"), ("error", "ok")]),
"Шаг 2 (file.export) не выполнен: ok"
);
assert_eq!(fmt(tr(ru, "Couldn't open {name}: {error}"), &[("name", "scan.tif"), ("error", "denied")]), "Не удалось открыть scan.tif: denied");
assert_eq!(fmt(tr(ru, "Exported {path} ({size})"), &[("path", "/tmp/a.png"), ("size", "12 KB")]), "Экспортировано: /tmp/a.png (12 KB)");
assert_eq!(fmt(tr(ru, "Data Set: {name} ({index}/{total})"), &[("name", "Set 1"), ("index", "1"), ("total", "2")]), "Набор данных: Set 1 (1/2)");
// Document and layer names stay verbatim.
let text = fmt(tr(ru, "Saved {path}; image assets: {n} in {dir}"), &[("path", "/tmp/a.pcraft"), ("n", "3"), ("dir", "/tmp/assets")]);
assert!(text.contains("/tmp/a.pcraft") && text.contains("/tmp/assets"), "{text}");
}
/// Every `tl!("literal")` in the shell has an entry in each language that claims complete menus
/// (so a new label can't ship untranslated by accident). Literals that are deliberately shown as
/// they are (names, units) are listed in `KEEP_AS_IS`.
@@ -1307,6 +1425,24 @@ mod tests {
}
}
/// The Italian catalog names the Dodge/Burn/Sponge *tools* and the blend modes the way Adobe
/// does. `Scherma` is the Dodge tool (with `Brucia` and `Spugna`) **and** the `Screen` blend
/// mode — Adobe's own collision, not ours — while `Schiarisci` is the `Lighten` blend mode and
/// must not be used for the tool. `Screen` used to read `Schermo`, "the display".
#[test]
fn italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does() {
let it = LANGUAGES.iter().find(|l| l.code == "it").expect("the Italian catalog is registered");
for (source, translation) in [("Dodge", "Scherma"), ("Dodge Tool", "Strumento Scherma"), ("Dodge/Burn", "Scherma/Brucia"), ("Screen", "Scherma")] {
assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}");
}
// The tool family stays together, and the blend modes keep Adobe's wording.
for (source, translation) in
[("Burn", "Brucia"), ("Sponge", "Spugna"), ("Lighten", "Schiarisci"), ("Color Dodge", "Colore scherma"), ("Linear Dodge", "Scherma lineare")]
{
assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}");
}
}
// Dynamic dialog labels generated by label_of() are not seen by the tl! scanner.
#[test]
fn color_lookup_export_scope_labels_are_translated() {
+6
View File
@@ -1467,6 +1467,7 @@
Set shape fill type Vultype van vorm instellen
Set shape stroke type Lijntype van vorm instellen
Set the text color De tekstkleur instellen
Create warped text Verbogen tekst maken
Shadows Schaduwen
Shape: Vorm:
Sharpening Verscherping
@@ -4098,3 +4099,8 @@ curves Smooth Vloeiend maken
Resize Windows to Fit Venstergrootte aanpassen
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Automatische alinearichting
Left-to-right paragraph direction Alinearichting van links naar rechts
Right-to-left paragraph direction Alinearichting van rechts naar links
Can't render this file because it contains an unexpected character in line 8 and column 34.
+6
View File
@@ -1498,6 +1498,7 @@
Set shape fill type Ustaw typ wypełnienia kształtu
Set shape stroke type Ustaw typ obrysu kształtu
Set the text color Ustaw kolor tekstu
Create warped text Utwórz wypaczony tekst
Shadows Cienie
Shape: Kształt:
Sharpening Wyostrzanie
@@ -3954,3 +3955,8 @@ curves Smooth Wygładź
Resize Windows to Fit Dopasuj rozmiar okien
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Automatyczny kierunek akapitu
Left-to-right paragraph direction Kierunek akapitu od lewej do prawej
Right-to-left paragraph direction Kierunek akapitu od prawej do lewej
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -1463,6 +1463,7 @@
Set shape fill type Definir tipo de preenchimento da forma
Set shape stroke type Definir tipo de traçado da forma
Set the text color Definir a cor do texto
Create warped text Criar texto deformado
Shadows Sombras
Shape: Forma:
Sharpening Nitidez
@@ -4277,3 +4278,8 @@ layerLabel No Color Sem cor
Resize Windows to Fit Redimensionar janelas para caber
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Direção automática do parágrafo
Left-to-right paragraph direction Direção do parágrafo da esquerda para a direita
Right-to-left paragraph direction Direção do parágrafo da direita para a esquerda
Can't render this file because it contains an unexpected character in line 6 and column 47.
File diff suppressed because it is too large Load Diff
+6
View File
@@ -1446,6 +1446,7 @@
Set shape fill type Podesi vrstu popune oblika
Set shape stroke type Podesi vrstu obruba oblika
Set the text color Postavi boju teksta
Create warped text Napravi iskrivljeni tekst
Shadows Senke
Shape: Oblik:
Sharpening Izoštravanje
@@ -4410,3 +4411,8 @@ shape Appearance Izgled
Resize Windows to Fit Prilagodi veličinu prozora
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Automatski smer pasusa
Left-to-right paragraph direction Smer pasusa sleva nadesno
Right-to-left paragraph direction Smer pasusa zdesna nalevo
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -100,6 +100,7 @@ mod tests {
(vec!["tr-TR", "en-US"], "tr"),
(vec!["sr-Latn-RS", "en-US"], "sr"),
(vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"),
(vec!["hi-IN", "en-US"], "hi"),
(vec![], "en"),
] {
with_system_tags(&tags, || assert_eq!(system_lang().code(), expected));
+6
View File
@@ -1444,6 +1444,7 @@
Set shape fill type Şekil doldurma türünü ayarla
Set shape stroke type Şekil kontur türünü ayarla
Set the text color Metin rengini ayarla
Create warped text Çarpık metin oluştur
Shadows Gölgeler
Shape: Şekil:
Sharpening Keskinleştirme
@@ -4068,3 +4069,8 @@ cameraData Lens Lens
Resize Windows to Fit Pencereleri sığacak şekilde boyutlandır
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Otomatik paragraf yönü
Left-to-right paragraph direction Soldan sağa paragraf yönü
Right-to-left paragraph direction Sağdan sola paragraf yönü
Can't render this file because it contains an unexpected character in line 8 and column 34.
+6
View File
@@ -760,6 +760,7 @@
Set shape fill type Установити тип заливки фігури
Set shape stroke type Установити тип обведення фігури
Set the text color Установити колір тексту
Create warped text Створити деформований текст
Only Web Colors Лише вебкольори
Overlay Color Колір накладання
Overlay Opacity Непрозорість накладання
@@ -4047,3 +4048,8 @@ curves Smooth Згладити
Resize Windows to Fit Підганяти розмір вікон
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Автоматичний напрямок абзацу
Left-to-right paragraph direction Напрямок абзацу зліва направо
Right-to-left paragraph direction Напрямок абзацу справа наліво
Can't render this file because it contains an unexpected character in line 1395 and column 18.
+6
View File
@@ -870,6 +870,7 @@
Right align text Căn chữ phải
Toggle text orientation Đổi hướng chữ
Set the text color Đặt màu chữ
Create warped text Tạo chữ uốn
Cancel any current edits (Esc) Hủy chỉnh sửa hiện tại (Esc)
Commit any current edits ({key}) Áp dụng chỉnh sửa hiện tại ({key})
Author: Tác giả:
@@ -3830,3 +3831,8 @@ curves Smooth Làm mượt
Resize Windows to Fit Đổi cỡ cửa sổ cho vừa ảnh
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction Hướng đoạn văn tự động
Left-to-right paragraph direction Hướng đoạn văn từ trái sang phải
Right-to-left paragraph direction Hướng đoạn văn từ phải sang trái
Can't render this file because it contains an unexpected character in line 6 and column 47.
+11
View File
@@ -1517,6 +1517,7 @@
Set shape fill type 设置形状填充类型
Set shape stroke type 设置形状描边类型
Set the text color 设置文字颜色
Create warped text 创建变形文字
Shadows 阴影
Shape: 形状:
Sharpening 锐化
@@ -1666,6 +1667,7 @@
Brush Pose 画笔姿态
Brush Tip Shape 画笔笔尖形状
Brush Tool 画笔工具
Pencil Tool 铅笔工具
Build-up 累积
Burn Tool 加深工具
Centimeters 厘米
@@ -1720,6 +1722,7 @@
ExtraBold 特粗
ExtraLight 特细
Eyedropper Tool 吸管工具
Color Sampler Tool 颜色取样器工具
Fade 渐隐
Fill Opacity 填充不透明度
Film & Video 电影与视频
@@ -1736,6 +1739,7 @@
Highlights from: 高光下限:
History Brush Tool 历史记录画笔工具
Horizontal Type Tool 横排文字工具
Vertical Type Tool 直排文字工具
Horizontal Type Mask Tool 横排文字蒙版工具
Vertical Type Mask Tool 直排文字蒙版工具
Image Size: 图像大小:
@@ -1867,9 +1871,11 @@
Tabloid Tabloid 纸
Text Replacement 文字替换
Thin 纤细
To 到
Traditional (72.27 points/inch) 传统(72.27 点/英寸)
Transfer 传递
Triangle Tool 三角形工具
Up 向上
Unsaved changes 未保存的更改
Uses the foreground and background colours. 使用前景色和背景色。
Watercolour look: lighter interior, darker rims. 水彩效果:内部较浅,边缘较深。
@@ -2991,3 +2997,8 @@ curves Smooth 平滑
Resize Windows to Fit 调整窗口大小以适合
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction 自动段落方向
Left-to-right paragraph direction 从左到右的段落方向
Right-to-left paragraph direction 从右到左的段落方向
Can't render this file because it contains an unexpected character in line 2357 and column 12.
+6
View File
@@ -1497,6 +1497,7 @@
Set shape fill type 設定形狀填滿類型
Set shape stroke type 設定形狀筆畫類型
Set the text color 設定文字顏色
Create warped text 建立彎曲文字
Shadows 陰影
Shape: 形狀:
Sharpening 銳利化
@@ -2960,3 +2961,8 @@ curves Smooth 平滑
Resize Windows to Fit 調整視窗大小以符合
EXR EXR
# Paragraph direction (TYPE-218-2)
Automatic paragraph direction 自動段落方向
Left-to-right paragraph direction 由左至右的段落方向
Right-to-left paragraph direction 由右至左的段落方向
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -151,6 +151,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("symmetry-vertical", include_bytes!("../../../assets/icons/symmetry-vertical.svg")),
("swatch-book", include_bytes!("../../../assets/icons/swatch-book.svg")),
("text-cursor", include_bytes!("../../../assets/icons/text-cursor.svg")),
("text-warp", include_bytes!("../../../assets/icons/text-warp.svg")),
("toggle-left", include_bytes!("../../../assets/icons/toggle-left.svg")),
("toggle-right", include_bytes!("../../../assets/icons/toggle-right.svg")),
("trash", include_bytes!("../../../assets/icons/trash.svg")),
+23
View File
@@ -1520,6 +1520,26 @@ impl PhotocraftApp {
self.session.prefs.edit(|p| p.file_handling.recent_files = r);
}
/// Records a picked type family for the font menu's Recent group, capped by
/// Preferences ▸ Type ▸ Recent Fonts (0 keeps no list), most recent first, de-duplicated.
pub fn note_recent_font(&mut self, family: &str) {
let cap = self.session.prefs().type_.recent_fonts.min(50) as usize;
let r = &mut self.ui.recent_fonts;
r.retain(|f| f != family);
if cap == 0 {
r.clear();
return;
}
r.insert(0, family.to_string());
r.truncate(cap);
}
/// Preferences ▸ Type ▸ Recent Fonts: a smaller cap (or 0) applies to the existing list at
/// once, not only after the next pick.
pub fn sync_recent_fonts(&mut self) {
self.ui.recent_fonts.truncate(self.session.prefs().type_.recent_fonts.min(50) as usize);
}
/// File › Open Recent › Clear Recent File List.
pub fn clear_recent(&mut self) {
self.ui.recent_files.clear();
@@ -2345,6 +2365,9 @@ impl eframe::App for PhotocraftApp {
field_wheel::begin_frame(&ctx);
// Each painting tool keeps its own brush (#218), so switch the active tool's brush in
// before anything this frame reads it (the cursor, the options bar, a stroke).
// Preferences ▸ Type ▸ Recent Fonts: a smaller cap (or 0) trims the existing list at
// once, not only after the next pick.
self.sync_recent_fonts();
paint_mouse::sync_tool_brush(self);
// A unit typed into a value field (`1in` in a px field) converts at the active document's
// resolution, as in Photoshop.
+1 -1
View File
@@ -546,7 +546,7 @@ fn language_node(pref: &str, lang: Lang) -> Node {
role: Some(ItemRole::Language),
};
let mut children = vec![Node::Item(item("auto", tr(lang, "Auto"))), Node::Separator];
children.extend(Lang::all().map(|l| Node::Item(item(l.code(), l.name()))));
children.extend(Lang::for_picker().into_iter().map(|l| Node::Item(item(l.code(), l.name()))));
Node::Submenu { label: tr(lang, "Language").into(), children, role: Some(ItemRole::Language) }
}
+1 -1
View File
@@ -1797,7 +1797,7 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, ke
Value::String(s) if path == "interface.language" => {
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
let mut pairs: Vec<(String, &str)> = vec![("auto".into(), tl!("Auto"))];
pairs.extend(crate::i18n::Lang::all().map(|l| (l.code().to_string(), l.name())));
pairs.extend(crate::i18n::Lang::for_picker().into_iter().map(|l| (l.code().to_string(), l.name())));
let mut cur = s.clone();
crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0);
obj.insert(k, json!(cur));
+9 -3
View File
@@ -231,10 +231,16 @@ fn character_and_paragraph_dock_panels_share_the_headers_without_type_options()
let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, [want], "tab {tab}");
}
// No type layer: the empty-state message, no sections.
// No type layer: the same dedicated sections edit Type tool defaults (#5556).
select(&mut h, px);
assert!(headers(&h).is_empty());
assert!(h.query_by_value("Select a type layer to edit its character and paragraph settings.").is_some());
for (tab, want) in [(0, "Character"), (1, "Paragraph")] {
set(&mut h, json!({"dockTabs": {"character": tab}}));
let hs = headers(&h);
assert!(hs.iter().all(|(_, r)| (r.height() - SECTION_H).abs() < 0.5), "{hs:?}");
let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, [want], "default tab {tab}");
}
assert!(h.query_by_value("Select a type layer to edit its character and paragraph settings.").is_none());
}
/// #2085: like Photoshop, Opacity and Fill stack in one right-aligned column: both fields end at
+27 -1
View File
@@ -656,6 +656,14 @@ pub struct ToolOptions {
pub type_size: f32,
pub type_aa: String,
pub type_align: String,
/// Character/Paragraph panel defaults beyond the options bar, in `type.setStyle`'s
/// parameter format. Kept as overrides so saved engine defaults still supply unset fields.
/// OpenType features and variable-font axes are merged per tag/axis.
#[serde(default)]
pub type_style_defaults: serde_json::Map<String, serde_json::Value>,
/// Type tool: paragraph direction of new type, "auto" (from the first strong character),
/// "ltr" or "rtl" (TYPE-218-2).
pub type_direction: String,
/// Clone Stamp / Healing Brush.
pub clone_aligned: bool,
/// Pattern Stamp: lock the tile origin in document space across strokes.
@@ -956,6 +964,8 @@ impl Default for ToolOptions {
type_size: 48.0,
type_aa: "sharp".into(),
type_align: "left".into(),
type_style_defaults: serde_json::Map::new(),
type_direction: "auto".into(),
clone_aligned: true,
pattern_stamp_aligned: true,
pattern_stamp_impressionist: false,
@@ -1140,6 +1150,17 @@ pub enum MadeLayer {
Place,
}
/// A paragraph-box handle drag: the handle, and the box (text-space) and layer transform as
/// they were at the press. Every move recomputes from this snapshot, so ⌥ pressed or released
/// mid-drag mirrors about the centre the box started with — as if it had been held from the
/// start.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BoxResize {
pub handle: u8,
pub shape: [f32; 4],
pub aff: photocraft_geom::Affine,
}
/// In-progress inline type editing (Type tool). Offsets are character indices.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextEdit {
@@ -1161,7 +1182,7 @@ pub struct TextEdit {
pub dragging: bool,
/// Paragraph-box handle being dragged (0-3 corners from top-left clockwise, 4-7 top/right/bottom/left edges).
#[serde(skip)]
pub resize: Option<u8>,
pub resize: Option<BoxResize>,
/// IME composition in progress: (start, length) in characters. The preedit text lives in the
/// layer so it lays out like typed text; each IME update replaces it.
#[serde(skip)]
@@ -1262,6 +1283,10 @@ pub struct UiState {
/// Recently opened file paths, most-recent first (File › Open Recent). Capped; de-duplicated.
#[serde(default)]
pub recent_files: Vec<String>,
/// Recently picked type families, most-recent first (the font menu's Recent group). Capped by
/// Preferences ▸ Type ▸ Recent Fonts; de-duplicated.
#[serde(default)]
pub recent_fonts: Vec<String>,
/// Inline type editing session, if any.
#[serde(default)]
pub text_edit: Option<TextEdit>,
@@ -1507,6 +1532,7 @@ impl Default for UiState {
group_tools: Vec::new(),
spring: None,
recent_files: Vec::new(),
recent_fonts: Vec::new(),
text_edit: None,
caret_blink_time: 0.0,
text_edit_return_tool: None,
+3 -1
View File
@@ -191,7 +191,9 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] {
Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, magic_eraser_opacity),
Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity),
Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous),
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => reset!(type_font, type_style, type_size, type_aa, type_align),
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => {
reset!(type_font, type_style, type_size, type_aa, type_align, type_direction, type_style_defaults)
}
Tool::Zoom => reset!(zoom_scrubby),
Tool::SpotHealing => reset!(spot_type),
Tool::Healing => reset!(clone_aligned, clone_sample),
+61 -6
View File
@@ -13,6 +13,7 @@ struct State {
button: egui::Id,
popup: egui::Id,
preview: FontPreview,
recents: Vec<String>,
}
fn harness() -> Harness<'static, State> {
@@ -20,16 +21,21 @@ fn harness() -> Harness<'static, State> {
}
fn harness_with(preview: FontPreview) -> Harness<'static, State> {
harness_with_recents(preview, Vec::new())
}
fn harness_with_recents(preview: FontPreview, recents: Vec<String>) -> Harness<'static, State> {
// Enough families that the unfiltered menu hits its maximum height and scrolls.
let mut families: Vec<String> = (0..60).map(|i| format!("Family {i:02}")).collect();
families.push("Zeta Sans".into());
let state = State { current: "Family 00".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview };
let state = State { current: "Family 00".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview, recents };
let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state(
|ui, s: &mut State| {
s.button = ui.make_persistent_id(egui::IdSalt::new("type-font"));
s.popup = s.button.with("popup");
let fams = s.families.clone();
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview);
let recents = s.recents.clone();
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &recents);
},
state,
);
@@ -332,14 +338,15 @@ fn arrows_on_the_field_step_through_the_families_after_a_pick() {
fn the_first_arrow_after_a_pick_keeps_the_focus_between_neighbours() {
for (enter, picked, stepped) in [(false, "Family 05", "Family 06"), (true, "Family 04", "Family 05")] {
let families: Vec<String> = (0..20).map(|i| format!("Family {i:02}")).collect();
let state = State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off };
let state =
State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off, recents: Vec::new() };
let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state(
|ui, s: &mut State| {
let _ = ui.button("Above");
s.button = ui.make_persistent_id(egui::IdSalt::new("type-font"));
s.popup = s.button.with("popup");
let fams = s.families.clone();
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview);
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &s.recents);
let _ = ui.button("Below");
},
state,
@@ -647,12 +654,14 @@ fn localized_font_names_label_the_menu_but_pick_the_family() {
button: egui::Id::NULL,
popup: egui::Id::NULL,
preview: FontPreview::Medium,
recents: Vec::new(),
};
let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state(
|ui, s: &mut State| {
s.popup = ui.make_persistent_id(egui::IdSalt::new("type-font")).with("popup");
let fams = s.families.clone();
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview);
let recents = s.recents.clone();
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &recents);
},
state,
);
@@ -687,7 +696,7 @@ fn font_menu_rows_search_both_names_and_sort_by_label() {
let labels: HashMap<String, String> =
[("MS Gothic", "MS ゴシック"), ("Microsoft YaHei", "微软雅黑")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
let rows = |labels: Option<&HashMap<String, String>>, q: &str| -> Vec<(String, String)> {
super::font_menu_rows(&families, labels, q).into_iter().map(|(f, l)| (f.clone(), l.to_string())).collect()
super::font_menu_rows(&families, labels, q, &[]).into_iter().map(|(f, l)| (f.clone(), l.to_string())).collect()
};
let english = rows(None, "");
assert!(english.iter().all(|(f, l)| f == l));
@@ -698,6 +707,28 @@ fn font_menu_rows_search_both_names_and_sort_by_label() {
assert!(rows(Some(&labels), "nothing").is_empty());
}
/// Preferences ▸ Type ▸ Recent Fonts: recently used families move to the top (most recent first,
/// once each); a query drops the hoisting so the list stays alphabetical while searching.
#[test]
fn recent_fonts_are_hoisted_to_the_top() {
let families: Vec<String> = ["Arial", "Baskerville", "Courier", "Zeta Sans"].iter().map(|s| s.to_string()).collect();
let recents: Vec<String> = vec!["Zeta Sans".into(), "Courier".into()];
let rows = |q: &str| -> Vec<String> { super::font_menu_rows(&families, None, q, &recents).into_iter().map(|(f, _)| f.clone()).collect() };
assert_eq!(rows(""), ["Zeta Sans", "Courier", "Arial", "Baskerville"], "recents hoist, most recent first, once each");
assert_eq!(rows("a"), ["Arial", "Baskerville", "Zeta Sans"], "a query drops the hoisting");
}
/// The menu itself draws the hoisted recents first.
#[test]
fn recent_fonts_appear_first_in_the_menu() {
let mut h = harness_with_recents(FontPreview::Medium, vec!["Zeta Sans".into(), "Family 05".into()]);
open(&mut h);
let zeta = h.get_by_label("Zeta Sans").rect().top();
let five = h.get_by_label("Family 05").rect().top();
let six = h.get_by_label("Family 06").rect().top();
assert!(zeta < five && five < six, "recents come first, in order: {zeta} {five} {six}");
}
#[test]
fn wheel_over_the_closed_font_button_steps_through_the_families() {
let mut h = harness();
@@ -717,6 +748,30 @@ fn wheel_over_the_closed_font_button_steps_through_the_families() {
assert_eq!(h.state().current, "Family 00", "stops at the first family");
}
#[test]
fn wheel_over_the_closed_button_keeps_a_stable_order_with_recents() {
let mut h = harness_with_recents(FontPreview::Medium, vec![]);
let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center();
h.hover_at(button);
h.run();
let wheel = |h: &mut Harness<'static, State>, y: f32| {
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE });
h.run();
};
wheel(&mut h, -1.0);
assert_eq!(h.state().current, "Family 01");
// The caller (the options bar) records the picked family as the most recent one.
h.state_mut().recents.insert(0, "Family 01".into());
h.run();
wheel(&mut h, -1.0);
assert_eq!(h.state().current, "Family 02", "the next notch keeps going, not back to the recent family");
h.state_mut().recents.insert(0, "Family 02".into());
h.run();
wheel(&mut h, -1.0);
assert_eq!(h.state().current, "Family 03");
assert!(popup_rect(&h).is_none(), "the list stays closed");
}
#[test]
fn wheel_family_index_clamps_to_the_ends() {
assert_eq!(super::wheel_family_index(0, 3, -5.0), Some(2));
+91 -3
View File
@@ -10,6 +10,11 @@ use crate::PhotocraftApp;
const TAG: u64 = 1 << 60;
/// How long the pointer has to rest on a served family that isn't fetched yet before hovering it
/// fetches it (seconds). Hovering asks the host for the whole family, and sweeping the font menu
/// would otherwise download every family the pointer passes; installed fonts preview at once.
const HOVER_FETCH_DELAY: f64 = 0.25;
#[derive(Clone)]
pub struct TypeFontPreview {
document: DocId,
@@ -25,15 +30,33 @@ pub struct TypeFontPreview {
pub(crate) fn follow_hover(app: &mut PhotocraftApp, ctx: &egui::Context) {
let hover_id = egui::Id::new("type-font-hover");
let since_id = egui::Id::new("type-font-hover-since");
let family = ctx.data_mut(|d| {
let family = d.get_temp::<String>(hover_id);
d.remove::<String>(hover_id);
family
});
if let Some(family) = family {
set(app, family);
} else if app.type_font_preview.is_some() {
clear(app);
let now = ctx.input(|i| i.time);
let since = ctx.data_mut(|d| match d.get_temp::<(String, f64)>(since_id) {
Some((f, t)) if f == family => t,
_ => {
d.insert_temp(since_id, (family.clone(), now));
now
}
});
let installed = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).fonts.has_family(&family);
let rested = now - since;
if installed || rested >= HOVER_FETCH_DELAY {
set(app, family);
} else {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(HOVER_FETCH_DELAY - rested));
}
} else {
ctx.data_mut(|d| d.remove::<(String, f64)>(since_id));
if app.type_font_preview.is_some() {
clear(app);
}
}
}
@@ -135,3 +158,68 @@ fn preview_key(revision: u64, layer: LayerId, range: Option<[usize; 2]>, family:
let h = family.bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0 ^ range_key, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
(h & (TAG - 1)) | TAG
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use serde_json::json;
/// One frame at `time` (seconds) with the pointer on `family`'s row in the font menu (`None`:
/// on no row), as the menu reports it each frame.
fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, family: Option<&str>, time: f64) {
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| {
if let Some(f) = family {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("type-font-hover"), f.to_string()));
}
follow_hover(app, ui.ctx());
});
out.textures_delta.clear();
}
fn previewed(app: &PhotocraftApp) -> Option<&str> {
app.type_font_preview.as_ref().and_then(|p| p.family.as_deref())
}
fn app_with_type_layer() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap();
app
}
#[test]
fn a_served_family_is_asked_for_only_once_the_pointer_rests_on_its_row() {
// Families the host serves but hasn't sent (own names: the served catalog is process-wide).
let (a, b) = ("Served Hover Rest A", "Served Hover Rest B");
let (fonts, _) = photocraft_text::served::parse_manifest(&format!("{a} | Regular | fonts/a/A.ttf\n{b} | Regular | fonts/b/B.ttf\n"));
photocraft_text::served::add_fonts(&fonts);
let mut app = app_with_type_layer();
let ctx = egui::Context::default();
// Passing over rows asks for nothing: each new row starts the wait again.
frame(&mut app, &ctx, Some(a), 1.0);
frame(&mut app, &ctx, Some(a), 1.0 + HOVER_FETCH_DELAY / 2.0);
frame(&mut app, &ctx, Some(b), 1.0 + HOVER_FETCH_DELAY);
frame(&mut app, &ctx, Some(a), 1.0 + HOVER_FETCH_DELAY * 1.5);
assert_eq!(previewed(&app), None);
// Resting on a row asks for the family and previews it.
frame(&mut app, &ctx, Some(a), 1.0 + HOVER_FETCH_DELAY * 3.0);
assert_eq!(previewed(&app), Some(a));
// Leaving the menu clears the preview, and the next hover waits again.
frame(&mut app, &ctx, None, 3.0);
assert_eq!(previewed(&app), None);
frame(&mut app, &ctx, Some(a), 3.1);
assert_eq!(previewed(&app), None);
}
#[test]
fn an_installed_family_previews_at_once() {
let mut app = app_with_type_layer();
let ctx = egui::Context::default();
frame(&mut app, &ctx, Some("JetBrains Mono"), 1.0);
assert_eq!(previewed(&app), Some("JetBrains Mono"));
}
}
+7 -4
View File
@@ -373,6 +373,8 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s
let defined = |attrs: &Value, k: &str| attrs.get(k).is_some();
// Preferences ▸ Type ▸ Font Preview Size sizes the family picker's samples.
let preview = app.session.prefs().type_.font_preview;
// The fonts the pickers hoist to the top (Preferences ▸ Type ▸ Recent Fonts).
let recents = app.ui.recent_fonts.clone();
// Preferences ▸ Units & Rulers ▸ Type: the model stores points; the fields may show pixels
// (by the document's resolution) or millimetres.
let (type_unit, unit_label) = crate::type_tool::type_unit(app);
@@ -399,7 +401,7 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s
ui.add_enabled_ui(on, |ui| match kind {
"font" => {
let mut f = v.as_str().unwrap_or("").to_string();
if family_picker(ui, &format!("style-font-{paragraph}-{id}"), &mut f, preview) {
if family_picker(ui, &format!("style-font-{paragraph}-{id}"), &mut f, preview, &recents) {
set = Some(json!({ panel: f }));
}
}
@@ -518,12 +520,12 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s
}
/// Searchable family combo box.
fn family_picker(ui: &mut egui::Ui, salt: &str, current: &mut String, preview: photocraft_engine::prefs::FontPreview) -> bool {
fn family_picker(ui: &mut egui::Ui, salt: &str, current: &mut String, preview: photocraft_engine::prefs::FontPreview, recents: &[String]) -> bool {
if !crate::type_tool::fonts_ready(ui, 140.0) {
return false;
}
let mut shown = if current.is_empty() { photocraft_text::fonts::DEFAULT_FAMILY.to_string() } else { current.clone() };
let changed = crate::type_tool::family_picker_in(ui, salt, &mut shown, 140.0, &crate::type_tool::families(), preview);
let changed = crate::type_tool::family_picker_in(ui, salt, &mut shown, 140.0, &crate::type_tool::families(), preview, recents);
if changed {
*current = shown;
}
@@ -617,7 +619,8 @@ pub fn glyphs_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Font family + style.
ui.horizontal(|ui| {
let mut f = family.clone();
if family_picker(ui, "glyph-family", &mut f, app.session.prefs().type_.font_preview) {
if family_picker(ui, "glyph-family", &mut f, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) {
app.note_recent_font(&f);
app.ui.type_panels.glyph_family = f;
app.ui.type_panels.glyph_style = tl!("Regular").into();
}
+498 -61
View File
@@ -15,7 +15,7 @@ use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::ViewXform;
use crate::state::TextEdit;
use crate::state::{BoxResize, TextEdit};
pub const PLACEHOLDER: &str = "Lorem Ipsum";
@@ -183,6 +183,14 @@ fn hit_offset(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize {
pub(crate) fn offset_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize {
let Some((l, aff, text)) = layout(app, id) else { return 0 };
let (tx, ty) = to_text(&aff, x, y);
// Type on a path: the point's place along the path is the position in the (single) line.
let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()));
let (tx, ty) = match shape {
Some(photocraft_doc::text::TextShape::Path { path, start, flip }) => {
photocraft_text::warp::arc_length_at(&path, flip, f64::from(tx), f64::from(ty)).map_or((tx, ty), |s| ((s - f64::from(start)) as f32, -1.0))
}
_ => (tx, ty),
};
photocraft_text::hit_char(&l, &text, tx, ty).0
}
@@ -232,18 +240,23 @@ pub(crate) fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64
.map(|i| i as u8)
}
/// Drag handle `i` to the document point: the dragged edges follow it, the others stay put.
fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) {
let id = LayerId(ed.layer);
let Some((bx, by, w, h)) = box_shape(app, id) else { return };
let Some((_, aff, _)) = layout(app, id) else { return };
let (px, py) = to_text(&aff, x, y);
let (l, r, t, b) = handle_sides(i);
/// Drag the handle of `r` (the box and transform snapshotted at the press) to the document
/// point: the dragged edges follow it, the others stay put. With ⌥ the opposite edges mirror
/// the drag about the centre the box started with, so the block grows symmetrically instead of
/// from one side — pressing or releasing ⌥ mid-drag switches that on and off as if it had been
/// held from the start.
fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, r: BoxResize, x: f64, y: f64, alt: bool) {
let (px, py) = to_text(&r.aff, x, y);
let (l, rw, t, b) = handle_sides(r.handle);
let (bx, by, w, h) = (r.shape[0], r.shape[1], r.shape[2], r.shape[3]);
let (mut x0, mut y0, mut x1, mut y1) = (bx, by, bx + w, by + h);
// The centre the ⌥ mirror turns about: the box as it stood at the press (every move
// recomputes from that snapshot, so the centre holds for the whole drag).
let (cx, cy) = (bx + w / 2.0, by + h / 2.0);
if l {
x0 = px.min(x1 - MIN_BOX);
}
if r {
if rw {
x1 = px.max(x0 + MIN_BOX);
}
if t {
@@ -252,8 +265,28 @@ fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) {
if b {
y1 = py.max(y0 + MIN_BOX);
}
if alt {
// The mirror keeps the centre fixed: the dragged edge clamps at MIN_BOX/2 from it, and
// the opposite edge follows, so the block never inverts below the minimum.
if l {
x0 = px.min(cx - MIN_BOX / 2.0);
x1 = 2.0 * cx - x0;
}
if rw {
x1 = px.max(cx + MIN_BOX / 2.0);
x0 = 2.0 * cx - x1;
}
if t {
y0 = py.min(cy - MIN_BOX / 2.0);
y1 = 2.0 * cy - y0;
}
if b {
y1 = py.max(cy + MIN_BOX / 2.0);
y0 = 2.0 * cy - y1;
}
}
// type.edit places the box's top-left at the given document point.
let o = aff.apply(Point::new(f64::from(x0), f64::from(y0)));
let o = r.aff.apply(Point::new(f64::from(x0), f64::from(y0)));
let _ = app.run("type.edit", json!({"layer": ed.layer, "box": [o.x, o.y, x1 - x0, y1 - y0], "coalesce": ed.session}));
}
@@ -271,8 +304,11 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie
if let Some(ed) = app.ui.text_edit.clone() {
let id = LayerId(ed.layer);
if let Some(i) = box_handle_at(app, id, x, y) {
if let Some(e) = app.ui.text_edit.as_mut() {
e.resize = Some(i);
// Snapshot the box and transform now: every move recomputes from them, so ⌥ held
// or released mid-drag mirrors about the centre the drag started with.
let snap = box_shape(app, id).zip(layout(app, id)).map(|((bx, by, w, h), (_, aff, _))| BoxResize { handle: i, shape: [bx, by, w, h], aff });
if let (Some(snap), Some(e)) = (snap, app.ui.text_edit.as_mut()) {
e.resize = Some(snap);
}
return true;
}
@@ -323,10 +359,10 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie
false
}
pub fn pointer_move(app: &mut PhotocraftApp, x: f64, y: f64) {
pub fn pointer_move(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) {
let Some(ed) = app.ui.text_edit.clone() else { return };
if let Some(i) = ed.resize {
resize_box(app, &ed, i, x, y);
if let Some(r) = ed.resize {
resize_box(app, &ed, r, x, y, mods.alt);
return;
}
if ed.dragging {
@@ -352,21 +388,17 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
// Preferences ▸ Type: "Fill new type layers with placeholder text" (on by default). The Type
// Mask tools start empty, as Photoshop's do.
let text = if !mask && app.session.prefs().type_.fill_new_type_layers_with_placeholder { PLACEHOLDER } else { "" };
let mut p = json!({
"text": text,
"orientation": if app.ui.tool.is_vertical_type() { "vertical" } else { "horizontal" },
"font": o.type_font,
"fontStyle": o.type_style,
"size": o.type_size,
"align": o.type_align,
"color": hex(app.session.tools.foreground),
});
let mut p = default_style_params(app);
p["text"] = json!(text);
p["orientation"] = json!(if app.ui.tool.is_vertical_type() { "vertical" } else { "horizontal" });
if w >= min && h >= min {
p["box"] = json!([start[0].min(end[0]).round(), start[1].min(end[1]).round(), w.round(), h.round()]);
} else {
p["x"] = json!(start[0].round());
p["y"] = json!(start[1].round());
}
// A click on a shown path starts type on that path, from the point clicked (Photoshop).
let on_path = if p.get("box").is_none() { path_at(app, start[0], start[1]) } else { None };
let key = session_key(app);
p["coalesce"] = json!(key);
if let Ok(v) = app.run("type.create", p)
@@ -375,6 +407,14 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
if o.type_aa != "sharp" {
let _ = app.run("type.edit", json!({"layer": id, "antialias": o.type_aa, "coalesce": key}));
}
if let Some(mut q) = on_path {
q["layer"] = json!(id);
q["at"] = json!([start[0], start[1]]);
q["coalesce"] = json!(key);
if let Err(e) = app.run("type.attachToPath", q) {
app.ui.status = e;
}
}
// A Type Mask's text is a hidden working layer: the canvas shows the mask overlay
// instead, and committing turns it into the selection.
if mask {
@@ -399,6 +439,53 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
}
}
/// The shown path a Type tool click at document point (`x`, `y`) puts type on, as
/// `type.attachToPath` params naming it (`shape`, `subpath`): the active shape layer's path, else
/// the work path, within a few screen pixels, as in Photoshop. Only the horizontal Type tool
/// starts path type for now; vertical type on a path is not laid out yet.
pub(crate) fn path_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<serde_json::Value> {
if app.ui.tool != crate::state::Tool::Type || app.ui.text_edit.is_some() || !app.ui.view.shows(app.ui.view.show.target_path) {
return None;
}
let tol = f64::from(6.0 / app.point_zoom().max(0.01));
let hit = |p: &photocraft_doc::vector::Path| {
let s = match photocraft_vector::edit::hit(p, Point::new(x, y), tol, &[])? {
photocraft_vector::edit::Hit::Anchor([s, _]) | photocraft_vector::edit::Hit::Segment([s, _], _) => s,
photocraft_vector::edit::Hit::Handle(..) => return None,
};
p.subpaths.get(s).filter(|sp| sp.knots.len() >= 2).map(|_| s)
};
if let Some((id, p)) = crate::vector_ui::active_shape_path(app)
&& let Some(s) = hit(&p)
{
return Some(json!({"shape": id, "subpath": s}));
}
let work = app.session.active()?.doc.work_path.as_ref()?;
hit(work).map(|s| json!({"subpath": s}))
}
/// The Type tool's pointer over a path it can follow: Photoshop's I-beam crossed by a dotted
/// wavy line, drawn on the canvas.
pub(crate) fn path_cursor(painter: &egui::Painter, at: Pos2) -> egui::CursorIcon {
let beam = |c: Color32, w: f32| {
let s = Stroke::new(w, c);
painter.line_segment([at + egui::vec2(0.0, -8.0), at + egui::vec2(0.0, 8.0)], s);
for dy in [-8.0, 8.0] {
painter.line_segment([at + egui::vec2(-3.0, dy), at + egui::vec2(3.0, dy)], s);
}
};
beam(Color32::WHITE, 3.0);
beam(Color32::BLACK, 1.0);
// The wave: short dashes along a sine through the beam's lower half.
let wave = |t: f32| at + egui::vec2(-9.0 + 18.0 * t, 5.0 - 3.0 * t + 2.0 * (t * std::f32::consts::TAU * 1.5).sin());
for i in (0..12).step_by(2) {
let (a, b) = (wave(i as f32 / 12.0), wave((i + 1) as f32 / 12.0));
painter.line_segment([a, b], Stroke::new(3.0, Color32::WHITE));
painter.line_segment([a, b], Stroke::new(1.0, Color32::BLACK));
}
egui::CursorIcon::None
}
fn session_key(app: &mut PhotocraftApp) -> String {
format!("type-{}", app.ui.alloc_id())
}
@@ -1140,10 +1227,24 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
let p = aff.apply(Point::new(x as f64, y as f64));
xf.to_screen(p.x as f32, p.y as f32)
};
let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()));
// Type on a path: the caret, selection and underline follow the curve the glyphs sit on.
let bend = shape.as_ref().and_then(photocraft_text::warp::Warp::on_path);
// Line space (lines, clusters, carets) → screen: vertical type turns it 90° clockwise.
let scr = |x: f32, y: f32| -> Pos2 {
let (x, y) = l.to_text(x, y);
scr_t(x, y)
match &bend {
Some(w) => {
let (x, y) = w.apply(f64::from(x), f64::from(y));
scr_t(x as f32, y as f32)
}
None => scr_t(x, y),
}
};
// A line-space run from `x0` to `x1` at height `y`: sampled, so it bends with path type.
let run = |x0: f32, x1: f32, y: f32| -> Vec<Pos2> {
let n = if bend.is_some() { ((x1 - x0) / 4.0).ceil().clamp(1.0, 512.0) as usize } else { 1 };
(0..=n).map(|i| scr(x0 + (x1 - x0) * i as f32 / n as f32, y)).collect()
};
// Tell the OS where the caret is: this is what enables the IME and places its candidate window.
{
@@ -1157,7 +1258,6 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
// Frame: paragraph text shows its box with handles; point text an underline per line.
let mods = crate::workspace_ui::sticky_mods(app, painter.ctx().input(|i| i.modifiers));
let has_transform = crate::type_transform::draw(app, painter, xf, mods);
let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()));
let frame = Stroke::new(1.0, t.accent);
match shape {
Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => {
@@ -1176,7 +1276,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
for ln in &l.lines {
// Horizontal: just under the baseline; vertical: the column's centre line.
let y = if l.vertical { ln.baseline } else { ln.baseline + ln.descent * 0.25 };
painter.line_segment([scr(ln.x0.min(0.0), y), scr(ln.x1.max(ln.x0 + 1.0), y)], Stroke::new(1.0, t.accent.gamma_multiply(0.8)));
painter.add(egui::Shape::line(run(ln.x0.min(0.0), ln.x1.max(ln.x0 + 1.0), y), Stroke::new(1.0, t.accent.gamma_multiply(0.8))));
}
}
}
@@ -1189,7 +1289,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
let (x0, x1) = xs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (p, q)| (lo.min(*p), hi.max(*q)));
if x0 < x1 {
let y = ln.baseline + ln.descent * 0.5;
painter.line_segment([scr(x0, y), scr(x1, y)], Stroke::new(1.5, t.accent));
painter.add(egui::Shape::line(run(x0, x1, y), Stroke::new(1.5, t.accent)));
}
}
}
@@ -1206,8 +1306,15 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
x1 = x1.max(ln.x1 + (ln.ascent + ln.descent) * 0.25);
x0 = x0.min(ln.x1);
}
if x0 < x1 {
let (top, bot) = (ln.baseline - ln.ascent, ln.baseline + ln.descent);
let (top, bot) = (ln.baseline - ln.ascent, ln.baseline + ln.descent);
if bend.is_some() {
// On a path each glyph turns on its own, so each gets its own highlight.
// Glyphs past the end of the path are not drawn, so neither is their highlight.
let shown = |p: f32, q: f32| bend.as_ref().is_some_and(|w| w.place_glyph(f64::from(p + q) / 2.0) != photocraft_text::warp::Placement::Hidden);
for (p, q) in xs.into_iter().filter(|&(p, q)| shown(p, q)) {
painter.add(egui::Shape::convex_polygon(vec![scr(p, top), scr(q, top), scr(q, bot), scr(p, bot)], fill, Stroke::NONE));
}
} else if x0 < x1 {
painter.add(egui::Shape::convex_polygon(vec![scr(x0, top), scr(x1, top), scr(x1, bot), scr(x0, bot)], fill, Stroke::NONE));
}
}
@@ -1361,8 +1468,10 @@ fn localized_families() -> Arc<HashMap<String, String>> {
/// The font menu's rows: each family with the label it shows (its localized name when `labels`
/// has one, else the family), matching `query` (lowercase) by either name. With localized labels
/// the rows sort by label, so the menu stays alphabetical as shown.
fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap<String, String>>, query: &str) -> Vec<(&'a String, &'a str)> {
/// the rows sort by label, so the menu stays alphabetical as shown. Recently used families are
/// hoisted to the top, most recent first (Preferences ▸ Type ▸ Recent Fonts); a query hides the
/// hoisting so the list stays strictly alphabetical while searching.
fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap<String, String>>, query: &str, recents: &[String]) -> Vec<(&'a String, &'a str)> {
let label = |f: &'a String| labels.and_then(|l| l.get(f)).map_or(f.as_str(), String::as_str);
let mut rows: Vec<(&String, &str)> = families
.iter()
@@ -1372,6 +1481,16 @@ fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap<String,
if labels.is_some_and(|l| !l.is_empty()) {
rows.sort_by_cached_key(|(_, l)| l.to_lowercase());
}
if query.is_empty() {
let mut at = 0;
for r in recents {
if let Some(i) = rows.iter().position(|(f, _)| *f == r) {
let row = rows.remove(i);
rows.insert(at, row);
at += 1;
}
}
}
rows
}
@@ -1546,8 +1665,8 @@ pub(crate) fn fonts_ready(ui: &mut egui::Ui, width: f32) -> bool {
}
/// Searchable font-family combo box.
fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32, preview: photocraft_engine::prefs::FontPreview) -> bool {
family_picker_in(ui, "type-font", current, width, &families(), preview)
fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32, preview: photocraft_engine::prefs::FontPreview, recents: &[String]) -> bool {
family_picker_in(ui, "type-font", current, width, &families(), preview, recents)
}
/// Maximum height of the font menu.
@@ -1555,8 +1674,15 @@ const FONT_MENU_HEIGHT: f32 = 460.0;
/// [`font_picker`] over a given family list (tests pass their own).
#[cfg(test)]
fn font_picker_in(ui: &mut egui::Ui, current: &mut String, width: f32, families: &[String], preview: photocraft_engine::prefs::FontPreview) -> bool {
family_picker_in(ui, "type-font", current, width, families, preview)
fn font_picker_in(
ui: &mut egui::Ui,
current: &mut String,
width: f32,
families: &[String],
preview: photocraft_engine::prefs::FontPreview,
recents: &[String],
) -> bool {
family_picker_in(ui, "type-font", current, width, families, preview, recents)
}
/// Shared by the Type options, Character, style and Glyphs panels. Selection changes still
@@ -1568,6 +1694,7 @@ pub(crate) fn family_picker_in(
width: f32,
families: &[String],
preview: photocraft_engine::prefs::FontPreview,
recents: &[String],
) -> bool {
let mut changed = false;
// A font picked from the open menu (click or Enter): the field keeps the focus afterwards.
@@ -1612,7 +1739,7 @@ pub(crate) fn family_picker_in(
d.insert_temp(search_id, q.clone());
});
let ql = q.to_lowercase();
let rows = font_menu_rows(families, labels, &ql);
let rows = font_menu_rows(families, labels, &ql, recents);
if down || up {
let index = rows.iter().position(|(f, _)| *f == current);
let next = match index {
@@ -1711,9 +1838,10 @@ pub(crate) fn family_picker_in(
// the frame. Cancel it, so the arrow only steps the font.
ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
}
// The wheel's step: a negative notch is the next family.
// The wheel's step: a negative notch is the next family. Steps use a stable order (no
// recents hoisting), so recording the picked family cannot make the next step jump back.
let notches = f32::from(u8::from(up)) - f32::from(u8::from(down));
let rows = font_menu_rows(families, labels, "");
let rows = font_menu_rows(families, labels, "", &[]);
if let Some(i) = rows.iter().position(|(f, _)| *f == current)
&& let Some(n) = wheel_family_index(i, rows.len(), notches)
&& let Some((f, _)) = rows.get(n)
@@ -1733,7 +1861,10 @@ pub(crate) fn family_picker_in(
ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO);
}
let notches = crate::widgets::wheel_notches_of(ui, response.response.id.with("family-wheel"), hovered, 1.0);
let rows = font_menu_rows(families, labels, "");
// The wheel steps in a stable order: hoisting the family it just picked as recent
// would change the order between notches and can jump back to the previous family.
// The open menu still shows the recent families first.
let rows = font_menu_rows(families, labels, "", &[]);
if let Some(i) = rows.iter().position(|(f, _)| *f == current)
&& let Some(n) = wheel_family_index(i, rows.len(), notches)
&& let Some((f, _)) = rows.get(n)
@@ -1835,9 +1966,139 @@ fn drag_key(ctx: &egui::Context) -> Option<String> {
Some(format!("type-drag-{}-{}", id.value(), t.to_bits()))
}
/// Apply only the supplied properties to the text-edit scope or selected type layers.
/// The styles a new Type-tool layer will receive. Use the same command parameters for the
/// panels and creation so defaults also survive action recording/replay. The older options-bar
/// fields remain authoritative, and text colour continues to follow the foreground colour.
fn default_style_params(app: &PhotocraftApp) -> serde_json::Value {
let o = &app.ui.tool_options;
let mut p = json!({});
// Settings are editable JSON: only recognized formatting keys may reach type.create.
// In particular a stray `text`, `box`, or `coalesce` key must not become a create parameter.
let (mut c, mut para) = (photocraft_doc::text::CharStyle::default(), photocraft_doc::text::ParagraphStyle::default());
for (key, value) in &o.type_style_defaults {
// These belong to the existing options-bar fields. Apart from being redundant,
// parsing a saved font key here would wait for the background font scan.
if matches!(key.as_str(), "font" | "family" | "fontStyle" | "weight" | "italic" | "postscriptName") {
continue;
}
let one = json!({ (key): value });
if !finite_style_value(value)
|| photocraft_engine::type_cmds::check_size_tracking(&one).is_err()
|| photocraft_engine::type_cmds::check_kerning(&one).is_err()
{
continue;
}
if photocraft_engine::type_cmds::apply_char_props(&mut c, &one) | photocraft_engine::type_cmds::apply_para_props(&mut para, &one) {
p[key] = value.clone();
}
}
p["font"] = json!(o.type_font);
p["fontStyle"] = json!(o.type_style);
p["size"] = json!(o.type_size);
p["align"] = json!(o.type_align);
p["direction"] = json!(o.type_direction);
p["color"] = json!(hex(app.session.tools.foreground));
p
}
/// Style numbers become f32 in the engine. JSON itself permits finite f64 values too large
/// for that conversion; a hand-edited setting must not inject infinity into a new layer.
/// Style collections (features, axes, colour) contain only scalar values, so no recursion.
fn finite_style_value(value: &serde_json::Value) -> bool {
let scalar = |v: &serde_json::Value| match v {
serde_json::Value::Number(n) => n.as_f64().is_some_and(|n| (n as f32).is_finite()),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => false,
_ => true,
};
match value {
serde_json::Value::Array(v) => v.iter().all(scalar),
serde_json::Value::Object(v) => v.values().all(scalar),
v => scalar(v),
}
}
fn default_styles(app: &PhotocraftApp) -> (photocraft_doc::text::CharStyle, photocraft_doc::text::ParagraphStyle) {
let (mut c, mut para) = app.session.type_defaults.clone().unwrap_or_default();
let mut p = default_style_params(app);
// The saved panels can appear on the very first frame. Style parsing resolves the named
// font through shared(), which waits for the font scan; display the saved names meanwhile,
// and let fonts_ready/axis_sliders expose the font controls when try_shared is ready.
if photocraft_text::try_shared().is_none() {
c.font_family = app.ui.tool_options.type_font.clone();
c.font_style = app.ui.tool_options.type_style.clone();
c.postscript_name = None;
if let Some(p) = p.as_object_mut() {
p.remove("font");
p.remove("fontStyle");
}
}
photocraft_engine::type_cmds::apply_char_props(&mut c, &p);
photocraft_engine::type_cmds::apply_para_props(&mut para, &p);
(c, para)
}
/// Change tool defaults without creating a document edit. The engine owns the style schema;
/// keep its property names rather than maintaining another style parser in the shell.
fn apply_defaults(app: &mut PhotocraftApp, props: &serde_json::Value) {
let Some(props) = props.as_object() else { return };
let o = &mut app.ui.tool_options;
for (key, value) in props {
match key.as_str() {
"font" => {
if let Some(v) = value.as_str() {
o.type_font = v.into();
}
}
"fontStyle" => {
if let Some(v) = value.as_str() {
o.type_style = v.into();
// A newly chosen named style replaces a weight-axis setting, as it does
// in apply_char_props. Other axes retain their independently chosen values.
if let Some(axes) = o.type_style_defaults.get_mut("variations").and_then(serde_json::Value::as_object_mut) {
axes.remove("wght");
}
}
}
"size" => {
if let Some(v) = value.as_f64() {
o.type_size = v as f32;
}
}
"align" => {
if let Some(v) = value.as_str() {
o.type_align = v.into();
}
}
"direction" => {
if let Some(v) = value.as_str() {
o.type_direction = v.into();
}
}
"metricsAsShown" => {} // Defaults have no layer transform.
"features" | "variations" => {
if let Some(patch) = value.as_object() {
let old = o.type_style_defaults.entry(key.clone()).or_insert_with(|| json!({}));
if let Some(old) = old.as_object_mut() {
old.extend(patch.clone());
} else {
*old = value.clone();
}
}
}
_ => {
o.type_style_defaults.insert(key.clone(), value.clone());
}
}
}
}
/// Apply only the supplied properties to the text-edit scope or selected type layers; without
/// a type target, prepare the defaults for the next layer instead.
fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, props: serde_json::Value) {
let Some(mut p) = formatting_params(app) else { return };
let Some(mut p) = formatting_params(app) else {
apply_defaults(app, &props);
return;
};
if let Some(props) = props.as_object() {
for (key, value) in props {
p[key] = value.clone();
@@ -1911,7 +2172,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
// Font family and style menus (one placeholder in their place while the fonts load).
if fonts_ready(ui, 170.0 + ui.spacing().item_spacing.x + 110.0) {
if font_picker(ui, &mut fam, 170.0, app.session.prefs().type_.font_preview) {
if font_picker(ui, &mut fam, 170.0, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) {
app.note_recent_font(&fam);
app.ui.tool_options.type_font = fam.clone();
let st = styles(&fam);
style = if st.contains(&style) { style } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
@@ -1966,6 +2228,15 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
ui.spacing_mut().item_spacing.x = 8.0;
crate::widgets::vline(ui, 22.0);
ui.spacing_mut().item_spacing.x = 2.0;
for (key, tip, d) in direction_items() {
if direction_button(ui, d, o.type_direction == key, tip).clicked() {
app.ui.tool_options.type_direction = key.into();
apply(app, ui.ctx(), json!({"direction": key}));
}
}
ui.spacing_mut().item_spacing.x = 8.0;
crate::widgets::vline(ui, 22.0);
// Text colour swatch with a picker popup.
let c = shown
.as_ref()
@@ -1984,6 +2255,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if resp.clicked() {
open_color_picker(app, [c[0], c[1], c[2]]);
}
// Create warped text, after the swatch as in Photoshop (#5911): the Type › Warp Text… dialog.
let can_warp = crate::menus::is_enabled(app, "type.warpText");
if ui.add_enabled_ui(can_warp, |ui| crate::icons::button(ui, "text-warp", 24.0, false, tl!("Create warped text"))).inner.clicked()
&& let Err(e) = crate::menus::invoke(app, &ui.ctx().clone(), "type.warpText", json!({}))
{
app.ui.status = e;
app.ui.status_error = true;
}
if app.ui.text_edit.is_some() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
@@ -2121,7 +2400,7 @@ fn icon_label(ui: &mut egui::Ui, icon: &str, tip: &str) {
let (r, resp) = ui.allocate_exact_size(egui::vec2(crate::props_layout::LABEL_W, 22.0), egui::Sense::hover());
crate::icons::paint(ui, r, icon, 12.0, t.text_dim);
// Photoshop's Character panel icons scrub the field beside them.
crate::widgets::scrub_label(ui, &resp.on_hover_text(tip));
crate::widgets::scrub_label(ui, &resp.on_hover_text(tl!(tip)));
}
/// Labelled numeric field (Photoshop's icon + value pairs). Returns the new value when edited.
@@ -2169,6 +2448,47 @@ fn align_glyph(p: &egui::Painter, r: egui::Rect, a: photocraft_doc::text::TextAl
}
}
/// Paragraph direction choices for the Paragraph panel and the options bar (TYPE-218-2): the
/// `direction` value `type.setStyle` takes, the tooltip, and the direction it sets.
fn direction_items() -> [(&'static str, &'static str, photocraft_doc::text::TextDirection); 3] {
use photocraft_doc::text::TextDirection as D;
[
("auto", tl!("Automatic paragraph direction"), D::Auto),
("ltr", tl!("Left-to-right paragraph direction"), D::Ltr),
("rtl", tl!("Right-to-left paragraph direction"), D::Rtl),
]
}
/// A pilcrow over an arrow pointing the way the paragraph runs; Auto points both ways.
fn direction_glyph(p: &egui::Painter, r: egui::Rect, d: photocraft_doc::text::TextDirection, c: Color32) {
use photocraft_doc::text::TextDirection as D;
let g = p.layout_no_wrap("".to_string(), egui::FontId::proportional(11.0), c);
p.galley(egui::pos2(r.center().x - g.size().x / 2.0, r.top() - 2.0), g, c);
let (y, s) = (r.bottom() - 1.5, Stroke::new(1.3, c));
p.line_segment([egui::pos2(r.left(), y), egui::pos2(r.right(), y)], s);
let head = |x: f32, towards: f32| {
for dy in [-3.0, 3.0] {
p.line_segment([egui::pos2(x, y), egui::pos2(x - towards * 3.0, y + dy)], s);
}
};
if d != D::Rtl {
head(r.right(), 1.0);
}
if d != D::Ltr {
head(r.left(), -1.0);
}
}
/// The options bar's 24 px square button for one paragraph direction, styled like its icon buttons.
fn direction_button(ui: &mut egui::Ui, d: photocraft_doc::text::TextDirection, selected: bool, tip: &str) -> egui::Response {
let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click());
let tint = crate::icons::button_chrome(ui, r, selected, resp.hovered());
direction_glyph(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(14.0, 16.0)), d, tint);
let tip = tip.to_string();
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, &tip));
resp.on_hover_text(tip)
}
/// A toggle button (faux styles, paragraph alignment) of width `w`, painted by `paint`.
fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnce(&egui::Ui, egui::Rect, Color32)) -> bool {
let t = crate::theme::Tokens::get(ui.ctx());
@@ -2238,7 +2558,17 @@ fn opentype_row(app: &mut PhotocraftApp, ui: &mut egui::Ui, c: &photocraft_doc::
/// characters (or its whole text) while typing, else every selected type layer. The new state
/// is explicit, so it follows the button the user saw rather than the layer's first run.
pub(crate) fn toggle_opentype(app: &mut PhotocraftApp, id: &str, shown_on: bool) {
let Some(mut p) = formatting_params(app) else { return };
let Some(mut p) = formatting_params(app) else {
if let Some((tag, _, _)) = OPENTYPE_BUTTONS.iter().find(|(_, command, _)| *command == id) {
let props = match *tag {
"liga" => json!({"ligatures": !shown_on}),
"dlig" => json!({"discretionaryLigatures": !shown_on}),
_ => json!({"features": { (*tag): !shown_on }}),
};
apply_defaults(app, &props);
}
return;
};
p["on"] = json!(!shown_on);
let _ = app.run(id, p);
}
@@ -2305,28 +2635,22 @@ fn cell_width(avail: f32, n: usize) -> f32 {
/// Properties panel sections for a type layer: Character, Paragraph and Type Options (#155).
pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
type_sections(app, ui, true, true);
type_sections(app, ui, true, true, false);
type_options(app, ui);
}
/// Window › Character / Paragraph (#150): the same controls as Properties, in their own dock
/// group. Without a type layer (or a type edit) there is nothing to show.
/// group. Without a type target they edit the defaults for the next Type-tool layer.
pub fn character_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool) {
if styles_at(app).is_none() {
let t = crate::theme::Tokens::get(ui.ctx());
ui.add_space(8.0);
ui.label(egui::RichText::new(tl!("Select a type layer to edit its character and paragraph settings.")).color(t.text_dim));
return;
}
type_sections(app, ui, !paragraph, paragraph);
type_sections(app, ui, !paragraph, paragraph, true);
}
/// The Character and/or Paragraph sections, with the shared collapsible headers and fields that
/// fill the panel width (#155).
fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool) {
fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool, show_defaults: bool) {
use crate::props_layout::{COL_GAP, LABEL_GAP, LABEL_W, field_width, section};
use crate::theme::ROW_GAP;
let Some((c, para)) = styles_at(app) else { return };
let Some((c, para)) = styles_at(app).or_else(|| show_defaults.then(|| default_styles(app))) else { return };
// Preferences ▸ Units & Rulers ▸ Type, as in the options bar: the model keeps points.
let (u, unit) = type_unit(app);
let t = crate::theme::Tokens::get(ui.ctx());
@@ -2345,7 +2669,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
let mut ready = true;
row(ui, &mut |ui| {
ready = fonts_ready(ui, full);
if ready && font_picker(ui, &mut fam, full, app.session.prefs().type_.font_preview) {
if ready && font_picker(ui, &mut fam, full, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) {
app.note_recent_font(&fam);
app.ui.tool_options.type_font = fam.clone();
apply(app, ui.ctx(), json!({"font": fam}));
}
@@ -2522,6 +2847,19 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
}
});
ui.add_space(ROW_GAP);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (key, tip, d) in direction_items() {
let clicked = toggle_cell(ui, cw, para.direction == d, tip, |ui, r, col| {
let g = egui::Rect::from_center_size(r.center(), egui::vec2(14.0, 16.0));
direction_glyph(ui.painter(), g, d, col);
});
if clicked {
apply(app, ui.ctx(), json!({"direction": key}));
}
}
});
ui.add_space(ROW_GAP);
row(ui, &mut |ui| {
if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) {
apply(app, ui.ctx(), json!({"startIndent": v / u}));
@@ -2655,6 +2993,41 @@ mod tests {
app
}
/// Preferences ▸ Type ▸ Recent Fonts caps the recorded list; a re-pick moves to the front and
/// 0 keeps no list.
#[test]
fn note_recent_font_caps_and_de_duplicates() {
let mut app = app();
for f in ["A", "B", "C"] {
app.note_recent_font(f);
}
assert_eq!(app.ui.recent_fonts, ["C", "B", "A"]);
app.run("prefs.set", json!({"path": "type.recentFonts", "value": 2})).unwrap();
app.note_recent_font("D");
assert_eq!(app.ui.recent_fonts, ["D", "C"], "the cap trims the tail");
app.note_recent_font("C");
assert_eq!(app.ui.recent_fonts, ["C", "D"], "a re-pick moves to the front");
app.run("prefs.set", json!({"path": "type.recentFonts", "value": 0})).unwrap();
app.note_recent_font("E");
assert!(app.ui.recent_fonts.is_empty(), "0 keeps no list");
}
/// A smaller Recent Fonts cap (or 0) applies to the existing list at once, without waiting
/// for the next pick (CodeRabbit review of #3559).
#[test]
fn lowering_the_recent_fonts_cap_applies_at_once() {
let mut app = app();
for f in ["A", "B", "C"] {
app.note_recent_font(f);
}
app.run("prefs.set", json!({"path": "type.recentFonts", "value": 2})).unwrap();
app.sync_recent_fonts();
assert_eq!(app.ui.recent_fonts, ["C", "B"], "the smaller cap trims the tail without a pick");
app.run("prefs.set", json!({"path": "type.recentFonts", "value": 0})).unwrap();
app.sync_recent_fonts();
assert!(app.ui.recent_fonts.is_empty(), "0 clears the list");
}
fn layer_text(app: &PhotocraftApp) -> String {
let ed = app.ui.text_edit.as_ref().unwrap();
current_text(app, LayerId(ed.layer)).unwrap()
@@ -2775,6 +3148,62 @@ mod tests {
assert!(app.ui.text_edit.is_none());
}
/// TYPE-218-1: a Type tool click on the work path starts type on the path from the point
/// clicked, in one history step with the placeholder selected; a click off it is point text.
#[test]
fn click_on_a_path_starts_type_on_the_path() {
use photocraft_doc::text::TextShape;
let mut app = app();
app.session
.edit("path", |doc, _| {
doc.work_path = Some(photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(20.0, 100.0), (380.0, 100.0)])]));
Ok(())
})
.unwrap();
assert!(path_at(&app, 200.0, 101.0).is_some());
assert!(path_at(&app, 200.0, 160.0).is_none(), "off the path");
let before = app.session.active().unwrap().history.entries().len();
assert!(!pointer_down(&mut app, 200.0, 101.0, egui::Modifiers::NONE));
pointer_up(&mut app, [200.0, 101.0], [200.0, 101.0]);
let ed = app.ui.text_edit.clone().unwrap();
assert_eq!((ed.anchor, ed.caret), (0, PLACEHOLDER.chars().count()), "the placeholder is selected");
let shape = text_layer(&app.session.active().unwrap().doc, LayerId(ed.layer)).unwrap().shape.clone();
assert!(matches!(shape, TextShape::Path { start, flip: false, .. } if (start - 180.0).abs() < 0.5), "{shape:?}");
assert_eq!(app.session.active().unwrap().history.entries().len(), before + 1, "one step for the click");
insert(&mut app, "On the line");
assert_eq!(layer_text(&app), "On the line");
commit(&mut app);
// With the vertical tool a click does not start path type.
app.ui.tool = crate::state::Tool::VerticalType;
assert!(path_at(&app, 200.0, 101.0).is_none());
app.ui.tool = crate::state::Tool::Type;
pointer_up(&mut app, [200.0, 160.0], [200.0, 160.0]);
let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer);
assert_eq!(text_layer(&app.session.active().unwrap().doc, id).unwrap().shape, TextShape::Point);
}
/// A click in type on a path puts the caret at the glyph there along the curve, not where the
/// straight line would have it.
#[test]
fn caret_hits_type_on_a_path_along_the_curve() {
let mut app = app();
app.session
.edit("path", |doc, _| {
doc.work_path = Some(photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(300.0, 10.0), (300.0, 195.0)])]));
Ok(())
})
.unwrap();
pointer_up(&mut app, [300.0, 20.0], [300.0, 20.0]);
insert(&mut app, "ABCDEFGH");
let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer);
assert!(matches!(text_layer(&app.session.active().unwrap().doc, id).unwrap().shape, photocraft_doc::text::TextShape::Path { .. }));
// The type runs down the path: near its start the caret is at the beginning, far down at the end.
assert_eq!(offset_at(&mut app, id, 300.0, 18.0), 0);
// (The straight line would put both of these at the start: they are left of its first glyph.)
let (mid, far) = (offset_at(&mut app, id, 296.0, 60.0), offset_at(&mut app, id, 296.0, 190.0));
assert!(0 < mid && mid < far, "{mid} {far}");
}
/// Preferences ▸ Units & Rulers ▸ Type switches the display unit of type metrics: points by
/// default, pixels by the document's resolution, or millimetres.
#[test]
@@ -2850,9 +3279,9 @@ mod tests {
let steps = app.session.active().unwrap().history.entries().len();
// Top-left corner: the box keeps its bottom-right corner.
assert!(pointer_down(&mut app, 10.0, 10.0, egui::Modifiers::NONE));
assert_eq!(app.ui.text_edit.as_ref().unwrap().resize, Some(0));
pointer_move(&mut app, 20.0, 25.0);
pointer_move(&mut app, 30.0, 30.0);
assert_eq!(app.ui.text_edit.as_ref().unwrap().resize.map(|r| r.handle), Some(0));
pointer_move(&mut app, 20.0, 25.0, egui::Modifiers::NONE);
pointer_move(&mut app, 30.0, 30.0, egui::Modifiers::NONE);
pointer_up(&mut app, [10.0, 10.0], [30.0, 30.0]);
assert_eq!(box_shape(&app, id), Some((0.0, 0.0, 80.0, 30.0)));
let aff = layout(&mut app, id).unwrap().1;
@@ -2862,9 +3291,17 @@ mod tests {
assert!(app.ui.text_edit.is_some());
// Right edge: only the width changes, and never below the minimum.
assert!(pointer_down(&mut app, 110.0, 45.0, egui::Modifiers::NONE));
pointer_move(&mut app, 0.0, 99.0);
pointer_move(&mut app, 0.0, 99.0, egui::Modifiers::NONE);
pointer_up(&mut app, [110.0, 45.0], [0.0, 99.0]);
assert_eq!(box_shape(&app, id), Some((0.0, 0.0, MIN_BOX, 30.0)));
// ⌥ mirrors the drag about the centre: the left edge follows the right one out.
assert!(pointer_down(&mut app, 34.0, 45.0, egui::Modifiers::ALT));
pointer_move(&mut app, 70.0, 45.0, egui::Modifiers::ALT);
pointer_up(&mut app, [34.0, 45.0], [70.0, 45.0]);
assert_eq!(box_shape(&app, id), Some((0.0, 0.0, MIN_BOX + 72.0, 30.0)));
let aff = layout(&mut app, id).unwrap().1;
// Text space −36..40 (centre 2) through the translation: the doc centre stays at 32.
assert_eq!((aff.m[4], aff.m[5]), (-6.0, 30.0));
}
#[test]
+395
View File
@@ -74,6 +74,240 @@ fn numeric_type_fields_have_localized_accessible_names() {
assert_eq!(*h.state(), 36.0);
}
/// #5556: the dedicated panels remain usable before there is any type layer, including
/// before opening a document. Properties still belongs to the selected layer.
#[test]
fn type_panels_show_default_controls_without_a_type_target() {
for app in [PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()), new_app()] {
let mut h = Harness::builder().with_size(vec2(760.0, 800.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
ui.columns(2, |columns| {
super::character_panel(app, &mut columns[0], false);
super::character_panel(app, &mut columns[1], true);
});
},
app,
);
PhotocraftApp::setup_context(&h.ctx, Default::default());
h.run_steps(4);
h.get_by_label("Faux Bold");
h.get_by_label("Fractions");
h.get_by_label("Center text");
h.get_by_label("Hyphenate");
h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Font size");
}
}
mod panel_defaults {
use super::*;
use crate::state::{Tool, ToolOptions};
use egui::accesskit::{Role, Toggled};
use egui_kittest::kittest::NodeT;
use photocraft_doc::text::{Caps, Kerning, Orientation, TextAlign, TextShape};
fn panels(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(760.0, 800.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
ui.columns(2, |columns| {
super::super::character_panel(app, &mut columns[0], false);
super::super::character_panel(app, &mut columns[1], true);
});
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, Default::default());
h.run_steps(4);
h
}
fn number(h: &mut Harness<'static, PhotocraftApp>, label: &str, value: &str) {
h.get_by_role_and_label(Role::SpinButton, label).click();
h.run();
h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text(value.into()));
h.key_press(egui::Key::Enter);
h.run();
}
#[test]
fn panel_defaults_are_editable_and_reach_every_type_tool() {
let mut h = panels(new_app());
let revision = h.state().session.active().unwrap().revision;
let layers = h.state().session.active().unwrap().doc.walk().len();
number(&mut h, "Font size", "36");
number(&mut h, "Tracking (1/1000 em)", "80");
number(&mut h, "Leading (set to the font size × auto-leading when Auto)", "44");
number(&mut h, "Indent left margin", "9");
for label in ["Faux Bold", "Fractions", "Standard Ligatures", "Discretionary Ligatures", "Center text", "Hyphenate"] {
h.get_by_label(label).click();
h.run();
}
assert_eq!(h.get_by_label("Fractions").accesskit_node().toggled(), Some(Toggled::True));
assert_eq!(h.get_by_label("Standard Ligatures").accesskit_node().toggled(), Some(Toggled::False));
assert_eq!(h.state().session.active().unwrap().revision, revision, "pre-styling does not create document history");
assert_eq!(h.state().session.active().unwrap().doc.walk().len(), layers);
let options = h.state().ui.tool_options.clone();
for tool in [Tool::Type, Tool::VerticalType, Tool::HorizontalTypeMask, Tool::VerticalTypeMask] {
for paragraph in [false, true] {
let mut app = new_app();
app.ui.tool_options = options.clone();
app.ui.tool = tool;
app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0];
let revision = app.session.active().unwrap().revision;
super::super::pointer_up(&mut app, [100.0, 100.0], if paragraph { [500.0, 400.0] } else { [100.0, 100.0] });
let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer);
let t = text(&app, id);
let c = &t.char_runs()[0].style;
let p = &t.paragraph_runs()[0].style;
assert_eq!(c.size_pt, 36.0);
assert_eq!(c.tracking, 80.0);
assert_eq!(c.leading_pt, Some(44.0));
assert!(c.faux_bold && c.discretionary_ligatures && !c.ligatures);
assert!(photocraft_engine::type_extra_cmds::feature_on(c, "frac"));
assert_eq!(c.color.to_rgba8(), [51, 102, 153, 255]);
assert_eq!(p.align, TextAlign::Center);
assert_eq!(p.start_indent_pt, 9.0);
assert!(p.hyphenate);
assert_eq!(t.orientation, if tool.is_vertical_type() { Orientation::Vertical } else { Orientation::Horizontal });
assert_eq!(matches!(t.shape, TextShape::Box { .. }), paragraph);
assert_eq!(app.ui.text_edit.as_ref().unwrap().mask, tool.is_type_mask());
assert!(app.session.active().unwrap().revision > revision);
super::super::cancel(&mut app);
assert!(app.session.active().unwrap().doc.layer(id).is_none(), "creating and formatting is one undoable edit");
assert_eq!(app.ui.tool_options, options, "cancelling text does not discard the chosen defaults");
}
}
}
#[test]
fn extended_defaults_merge_serialize_and_reset_without_replacing_legacy_options() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
super::super::apply(
&mut app,
&ctx,
json!({
"fontStyle": "Bold", "size": 32, "kerning": 25, "caps": "smallCaps",
"baselineShift": 3, "horizontalScale": 90, "verticalScale": 110,
"underline": true, "strikethrough": true, "fauxItalic": true,
"firstLineIndent": 5, "endIndent": 7, "spaceBefore": 11, "spaceAfter": 13,
"features": {"swsh": 1}, "variations": {"wght": 650, "wdth": 90}
}),
);
super::super::apply(&mut app, &ctx, json!({"features": {"frac": 1}, "variations": {"slnt": -8}}));
let (c, p) = super::super::default_styles(&app);
assert_eq!((c.size_pt, c.kern, c.kerning, c.caps), (32.0, 25.0, Kerning::Off, Caps::SmallCaps));
assert_eq!((c.baseline_shift_pt, c.horizontal_scale, c.vertical_scale), (3.0, 0.9, 1.1));
assert!(c.underline && c.strikethrough && c.faux_italic);
assert_eq!((p.first_line_indent_pt, p.end_indent_pt, p.space_before_pt, p.space_after_pt), (5.0, 7.0, 11.0, 13.0));
assert_eq!(c.features.iter().map(|f| f.tag.as_str()).collect::<Vec<_>>(), ["frac", "swsh"]);
assert_eq!(c.variations.iter().map(|v| (v.axis.as_str(), v.value)).collect::<Vec<_>>(), [("slnt", -8.0), ("wdth", 90.0), ("wght", 650.0)]);
// A named style replaces wght; it must not come back when a later layer is created.
super::super::apply(&mut app, &ctx, json!({"fontStyle": "Regular"}));
let (c, _) = super::super::default_styles(&app);
assert!(!c.variations.iter().any(|v| v.axis == "wght"));
assert_eq!(c.variations.len(), 2);
let saved = serde_json::to_value(&app.ui.tool_options).unwrap();
let restored: ToolOptions = serde_json::from_value(saved.clone()).unwrap();
assert_eq!(restored, app.ui.tool_options);
crate::tool_memory::persist(&mut app, &ctx);
let remembered = app.session.prefs().tool_options.clone();
let mut restarted = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
restarted.session.prefs.edit(|p| p.tool_options = remembered);
crate::tool_memory::restore(&mut restarted);
assert_eq!(restarted.ui.tool_options, restored, "the real preference restore path retains panel defaults");
let mut legacy = saved;
legacy.as_object_mut().unwrap().remove("type_style_defaults");
let legacy: ToolOptions = serde_json::from_value(legacy).unwrap();
assert_eq!((legacy.type_font.as_str(), legacy.type_style.as_str(), legacy.type_size), ("Inter", "Regular", 32.0));
assert!(legacy.type_style_defaults.is_empty());
for tool in [Tool::Type, Tool::VerticalType, Tool::HorizontalTypeMask, Tool::VerticalTypeMask] {
let mut reset = restored.clone();
crate::tool_reset::reset_options(&mut reset, tool);
assert!(reset.type_style_defaults.is_empty());
assert_eq!(reset.type_size, ToolOptions::default().type_size);
}
}
#[test]
fn selected_text_edits_leave_panel_defaults_and_other_characters_untouched() {
let mut app = new_app();
let ctx = egui::Context::default();
super::super::apply(&mut app, &ctx, json!({"tracking": 80, "fauxBold": true}));
let defaults = app.ui.tool_options.type_style_defaults.clone();
let id = LayerId(app.run("type.create", json!({"text": "AéBC", "size": 24})).unwrap()["layer"].as_u64().unwrap());
app.ui.text_edit = Some(crate::state::TextEdit {
layer: id.0,
document: None,
caret: 2,
anchor: 1,
session: "panel-default-selection".into(),
created: false,
mask: false,
dragging: false,
resize: None,
preedit: None,
});
super::super::apply(&mut app, &ctx, json!({"tracking": 15}));
super::super::toggle_opentype(&mut app, "type.openType.fractions", false);
let runs = text(&app, id).char_runs();
assert_eq!(runs.iter().map(|r| (r.len, r.style.tracking)).collect::<Vec<_>>(), [(1, 0.0), (2, 15.0), (2, 0.0)]);
assert!(photocraft_engine::type_extra_cmds::feature_on(&runs[1].style, "frac"));
assert!(!photocraft_engine::type_extra_cmds::feature_on(&runs[0].style, "frac"));
assert_eq!(app.ui.tool_options.type_style_defaults, defaults);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(text(&app, id).char_runs().len(), 1, "selection edits keep their typing-session coalescing");
assert_eq!(text(&app, id).char_runs()[0].style.tracking, 0.0);
}
#[test]
fn default_text_color_uses_foreground_and_cancel_preserves_it() {
let mut app = new_app();
let before = app.session.tools.foreground;
let revision = app.session.active().unwrap().revision;
let dialog = super::super::open_color_picker(&mut app, [0.0; 3]);
app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#ff8800"));
crate::dialogs::cancel(&mut app, dialog).unwrap();
assert_eq!(app.session.tools.foreground, before);
let dialog = super::super::open_color_picker(&mut app, [0.0; 3]);
app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#336699"));
crate::dialogs::confirm(&mut app, dialog).unwrap();
assert_eq!(super::super::default_styles(&app).0.color.to_rgba8(), [51, 102, 153, 255]);
assert_eq!(app.session.active().unwrap().revision, revision);
super::super::pointer_up(&mut app, [100.0, 100.0], [100.0, 100.0]);
let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer);
assert_eq!(text(&app, id).char_runs()[0].style.color.to_rgba8(), [51, 102, 153, 255]);
}
#[test]
fn unknown_saved_defaults_cannot_become_creation_parameters() {
let mut app = new_app();
app.ui.tool_options.type_style_defaults =
json!({"tracking": 20, "text": "Injected", "box": [0, 0, 2, 2], "coalesce": "unrelated"}).as_object().unwrap().clone();
let p = super::super::default_style_params(&app);
assert_eq!(p["tracking"], 20);
assert!(p.get("text").is_none() && p.get("box").is_none() && p.get("coalesce").is_none());
app.ui.tool_options.type_style_defaults = json!({
"tracking": 1e300, "leading": 1e300, "spaceAfter": 1e300,
"variations": {"wght": 1e300}, "features": {"frac": {"bad": 1}}, "fauxBold": true
})
.as_object()
.unwrap()
.clone();
let p = super::super::default_style_params(&app);
for key in ["tracking", "leading", "spaceAfter", "variations", "features"] {
assert!(p.get(key).is_none(), "hostile {key} is ignored");
}
let (c, para) = super::super::default_styles(&app);
assert!(c.faux_bold && c.leading_pt.is_none() && c.tracking == 0.0 && para.space_after_pt == 0.0);
}
}
fn harness(ppp: f32, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
make_harness(ppp, app, false)
}
@@ -1617,6 +1851,7 @@ fn the_font_menu_lists_served_families() {
mod multi_layer_formatting {
use super::*;
use egui::accesskit::Role;
use egui_kittest::kittest::NodeT;
use photocraft_doc::text::{AntiAlias, CharStyle, Orientation, TextAlign};
fn selected(app: &mut PhotocraftApp, ids: &[LayerId]) {
@@ -1696,6 +1931,62 @@ mod multi_layer_formatting {
});
}
/// The Paragraph section's direction buttons set the selected layers' paragraph direction in
/// one undo step, show the current one, and leave unselected layers and the tool's default for
/// new type alone (TYPE-218-2).
#[test]
fn paragraph_direction_buttons_set_and_show_the_direction() {
use egui::accesskit::Toggled;
use egui_kittest::kittest::NodeT;
use photocraft_doc::text::TextDirection;
let (app, ids) = fixture();
let dir = |app: &PhotocraftApp, id: LayerId| text(app, id).paragraph_runs()[0].style.direction;
let shown = |h: &Harness<'static, PhotocraftApp>, label: &str| h.get_by_label(label).accesskit_node().toggled();
let mut h = controls(app, true);
assert_eq!(shown(&h, "Automatic paragraph direction"), Some(Toggled::True), "new type follows its text");
assert_eq!(shown(&h, "Right-to-left paragraph direction"), Some(Toggled::False));
let steps = h.state().session.active().unwrap().history.entries().len();
h.get_by_label("Right-to-left paragraph direction").click();
h.run();
for id in &ids[..2] {
assert_eq!(dir(h.state(), *id), TextDirection::Rtl, "selected layers get the direction");
}
assert_eq!(dir(h.state(), ids[2]), TextDirection::Auto, "an unselected layer is untouched");
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 1, "one undo step");
assert_eq!(h.state().ui.tool_options.type_direction, "auto", "the panel edits layers, not new type");
h.run();
assert_eq!(shown(&h, "Right-to-left paragraph direction"), Some(Toggled::True), "the button shows it");
assert_eq!(shown(&h, "Automatic paragraph direction"), Some(Toggled::False));
h.get_by_label("Left-to-right paragraph direction").click();
h.run();
assert_eq!(dir(h.state(), ids[0]), TextDirection::Ltr);
assert!(h.state_mut().session.undo());
assert!(h.state_mut().session.undo());
for id in &ids[..2] {
assert_eq!(dir(h.state(), *id), TextDirection::Auto, "undo restores it");
}
}
/// The options bar's direction buttons set the selected layers' direction and the one new
/// type starts with, as its alignment buttons do (TYPE-218-2).
#[test]
fn options_bar_direction_sets_selected_layers_and_new_type() {
use photocraft_doc::text::TextDirection;
let (app, ids) = fixture();
let mut h = controls(app, false);
h.get_by_label("Right-to-left paragraph direction").click();
h.run();
assert_eq!(h.state().ui.tool_options.type_direction, "rtl");
for id in &ids[..2] {
assert_eq!(text(h.state(), *id).paragraph_runs()[0].style.direction, TextDirection::Rtl);
}
assert_eq!(text(h.state(), ids[2]).paragraph_runs()[0].style.direction, TextDirection::Auto);
super::super::pointer_up(h.state_mut(), [40.0, 60.0], [40.0, 60.0]);
let id = h.state().session.active().unwrap().active_layer.unwrap();
assert!(!ids.contains(&id), "a new type layer");
assert_eq!(text(h.state(), id).paragraph_runs()[0].style.direction, TextDirection::Rtl, "new type starts right to left");
}
#[test]
fn options_font_family_preserves_peer_faces_and_batches_layer_flags() {
let (app, ids) = fixture();
@@ -2038,6 +2329,28 @@ mod multi_layer_formatting {
assert_eq!(style(h.state(), id, 0), expected);
}
}
/// #5911: the options bar's Create warped text button opens Warp Text for the selected type
/// layers, as Type › Warp Text… does; with no type layer to warp it is greyed.
#[test]
fn options_bar_warp_button_warps_the_selected_type_layers() {
let (app, ids) = fixture();
let mut h = controls(app, false);
h.get_by_role_and_label(Role::Button, "Create warped text").click();
h.run();
let dialog = h.state().ui.dialogs.last().unwrap();
assert_eq!(dialog.fields.get("__command").and_then(|c| c.as_str()), Some("type.warpText"));
let dialog = dialog.id;
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("style".into(), json!("arc"));
crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
for id in &ids[..2] {
assert!(text(h.state(), *id).warp.is_some(), "a selected layer is warped");
}
assert!(text(h.state(), ids[2]).warp.is_none(), "an unselected layer is left alone");
h.state_mut().run("layer.new.layer", json!({})).unwrap();
h.run();
assert!(h.get_by_role_and_label(Role::Button, "Create warped text").accesskit_node().is_disabled());
}
}
/// #1132: with the Move tool, double-clicking type on the canvas edits it (Photoshop CC 2019+):
@@ -2475,3 +2788,85 @@ fn justify_buttons_are_greyed_on_point_text() {
h.run_steps(4);
assert!(h.get_by_label("Justify all").accesskit_node().is_disabled(), "one of the formatted layers is point text");
}
/// The paragraph box's document rect: its text-space origin through the layer transform, then
/// width and height. Only what the symmetric-resize tests need (identity linear part).
fn box_doc_rect(app: &PhotocraftApp, id: LayerId) -> [f64; 4] {
let t = text(app, id);
let (x, y, w, h) = match t.shape {
photocraft_doc::text::TextShape::Box { x, y, width, height } => (f64::from(x), f64::from(y), f64::from(width), f64::from(height)),
ref s => panic!("not a box: {s:?}"),
};
let p = t.transform.apply(Point::new(x, y));
[p.x, p.y, w, h]
}
/// ⌥ on an edge drag of a paragraph box mirrors the opposite edge about the box's centre, so
/// the block grows symmetrically; without ⌥ the opposite edge stays put.
#[test]
fn alt_dragging_a_box_edge_mirrors_the_opposite_edge() {
use crate::canvas::{ToolEvent, tool_event};
let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0));
app.ui.extras.snap = false;
// The box is at (300, 300), 240×180: its bottom-edge handle is the midpoint (420, 480).
tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 480.0, pressure: 1.0 }, Modifiers::NONE);
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 530.0, pressure: 1.0 }, Modifiers::NONE);
tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 530.0 }, Modifiers::NONE);
assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 230.0], "plain drag: the top edge stays");
tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 530.0, pressure: 1.0 }, Modifiers::ALT);
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 580.0, pressure: 1.0 }, Modifiers::ALT);
tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 580.0 }, Modifiers::ALT);
// The centre (300 + 115 = 415) held: the bottom followed the pointer (580), the top
// mirrored up to 250.
assert_eq!(box_doc_rect(&app, id), [300.0, 250.0, 240.0, 330.0]);
}
/// ⌥ pressed or released mid-drag applies as if it had been held from the start: the mirror
/// turns about the centre the box had at the press, not about wherever the box drifted.
#[test]
fn alt_pressed_mid_drag_mirrors_about_the_press_centre() {
use crate::canvas::{ToolEvent, tool_event};
let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0));
app.ui.extras.snap = false;
// Bottom edge (420, 480) dragged down 80 px without ⌥: only the bottom moves.
tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 480.0, pressure: 1.0 }, Modifiers::NONE);
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 520.0, pressure: 1.0 }, Modifiers::NONE);
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 560.0, pressure: 1.0 }, Modifiers::NONE);
assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 260.0]);
// ⌥ pressed mid-drag: the bottom follows the pointer, the top jumps to the mirror about
// the press centre (390): 2*390 − 600 = 180 — not about the drifted one (430).
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 600.0, pressure: 1.0 }, Modifiers::ALT);
assert_eq!(box_doc_rect(&app, id), [300.0, 180.0, 240.0, 420.0]);
// ⌥ released again mid-drag: the top snaps back to where a plain drag leaves it.
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 600.0, pressure: 1.0 }, Modifiers::NONE);
assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 300.0]);
tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 600.0 }, Modifiers::NONE);
}
/// ⌥ mirrors a side-edge drag the same way, and a corner drag grows both axes about the
/// centre; clamped, the ⌥ box stops at MIN_BOX about its centre instead of collapsing.
#[test]
fn alt_mirrors_side_and_corner_drags_and_clamps_at_the_minimum() {
use crate::canvas::{ToolEvent, tool_event};
let drag = |app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers| {
tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods);
tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods);
tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods);
};
// Right-edge handle: (540, 390) for the box at (300, 300), 240×180.
let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0));
app.ui.extras.snap = false;
drag(&mut app, [540.0, 390.0], [590.0, 390.0], Modifiers::ALT);
// cx = 420 held: the right edge followed to 590, the left mirrored from 300 to 250.
assert_eq!(box_doc_rect(&app, id), [250.0, 300.0, 340.0, 180.0]);
// Clamped: dragging the right edge past the centre leaves the minimum box about it.
drag(&mut app, [590.0, 390.0], [400.0, 390.0], Modifiers::ALT);
let r = box_doc_rect(&app, id);
assert_eq!(r[2], 4.0, "MIN_BOX wide");
assert!(((r[0] + r[2] / 2.0) - 420.0).abs() < 1e-9, "still centred on 420");
// Corner handle (bottom-right of the fresh box): ⌥ grows both axes about the centre.
let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0));
app.ui.extras.snap = false;
drag(&mut app, [540.0, 480.0], [590.0, 530.0], Modifiers::ALT);
assert_eq!(box_doc_rect(&app, id), [250.0, 250.0, 340.0, 280.0]);
}
+13 -2
View File
@@ -84,8 +84,8 @@ what an agent can't finish alone.
- **Estimate:** 80–140 h.
### G8. Type depth
- **Missing:** type on a path beyond rigid glyph placement (TYPE-218-1 partial, #4365),
paragraph direction UI (TYPE-218-2), complex-script fallback fonts and golden tests
- **Missing:** type on a path beyond rigid glyph placement (TYPE-218-1 partial, #4365; the Type tool's click on a shown path landed),
the visual RTL caret (TYPE-218-2; the paragraph direction buttons landed 2026-10-20), complex-script fallback fonts and golden tests
(TYPE-218-3), OpenType Numerals and Stylistic Sets submenus
(TYPE-218-5; the Character panel buttons landed 2026-10-11, #3752), text free transform
(#2630). Done since the re-measure: manual and optical kerning with PSD round trip
@@ -220,6 +220,9 @@ what an agent can't finish alone.
### G9. Panels, preferences and workspace
- **Progress (2026-10-15):** Preferences › General › Animated Zoom takes effect: a held Zoom
tool press zooms continuously about the press point, ⌥ out (#6210, `ui-egui/src/zoom_hold.rs`).
- **Progress (2026-10-14, #5556):** Character and Paragraph controls stay available without
a type target, editing persisted Type tool defaults inherited by new text. Formatting existing
selected text keeps its current command and undo scope. Other panel gaps remain open.
- **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086),
with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`).
- **Missing:** 24 of 171 preferences do nothing (scorecard prefs audit, 2026-10-15: Save in
@@ -292,6 +295,10 @@ what an agent can't finish alone.
as tofu (#1408, #1693).
- **Evidence (2026-10-11):** Czech dynamic UI wrappers, hardware labels and composed messages
translate at display sites; engine errors remain English. This does not close G13.
- **Evidence (2026-10-15):** Russian gained the 1,229 dynamic labels the other catalogs already
had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus,
metadata, System Info, status templates, dialogs), with a terminology note
([localization-ru](localization-ru.md)); engine errors remain English. This does not close G13.
- **Detail:** [`localization-parity.md`](localization-parity.md).
- **Estimate:** 80–140 h. **Human:** native-speaker review of every catalog (none is recorded).
@@ -308,7 +315,9 @@ what an agent can't finish alone.
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | G8: Auto / Left-to-right / Right-to-left paragraph direction buttons in the Paragraph panel, Properties and the Type options bar; TYPE-218-2 partial (the visual RTL caret is still missing) |
| 2026-10-19 | minor | G10: JPEG Options dialog landed (FILE-215-19); no readiness percentage or estimate changed |
| 2026-10-18 | minor | G13: Russian gained the 1,229 dynamic UI labels other catalogs already had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs); engine messages and native review remain open |
| 2026-10-15 | minor | G10: Cineon write landed (10-bit log gray/RGB, Save As `.cin`) |
| 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open |
| 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged |
@@ -337,8 +346,10 @@ what an agent can't finish alone.
| 2026-10-13 | minor | G1: a pass-through group that is a clipping base and holds an adjustment layer renders isolated; psd-tools passthrough_clipping_mask_adjustment passes; psd-tools floor → 240 of 309 |
| 2026-10-13 | minor | G1: 16-bit Posterize bins the 16-bit sample, as Photoshop does (exact on two oracles); psd-tools floor → 241 of 309 |
| 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 |
| 2026-10-14 | minor | G9: Character/Paragraph defaults before creating text (#5556); no percentage or estimate changed |
| 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed |
| 2026-10-13 | minor | G10: Amiga IFF/ILBM read landed (1–8 planes, CMAP, stored and ByteRun1); IFF write remains open |
| 2026-10-13 | minor | G8: a Type tool click on the work path or the active shape layer's path starts type on it at the point clicked (TYPE-218-1, partial); no readiness percentage or estimate changed |
| 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed |
| 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed |
| 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done |
+9
View File
@@ -44,9 +44,18 @@ command names joined by a slash, which keep both capitals (*Luminosità/Contrast
| HDR Toning | Mappatura tonale HDR | tone mapping; *Viraggio* stays for colour toning (*Viraggio separato*) |
| Bevel & Emboss | Smusso e rilievo | pairs with *Rilievo* (Emboss) |
| Linked (smart object) | Oggetto collegato | *Inserisci oggetto collegato*, *Converti in oggetto collegato* |
| Screen (blend mode) | Scherma | the blend mode; *Schermo* stays for the display (*Modalità schermo*, *Risoluzione schermo*) |
| Dodge / Burn / Sponge (tools) | Scherma / Brucia / Spugna | the *tools*, one family. *Scherma* is also the `Screen` blend mode (Adobe's own collision). *Schiarisci* is the `Lighten` blend mode and is never the Dodge tool |
## Native review
2026-10-13: `Screen` was *Schermo* — the display, not the blend mode — and is now *Scherma*, the
word Adobe uses. The Dodge/Burn/Sponge tools were left as they were (*Scherma*, *Brucia*, *Spugna*):
a first pass also wanted to rename the Dodge tool to *Schiarisci*, but that is the `Lighten` blend
mode and would have given two different things the same name. The wording is pinned by a test
(`i18n::tests::italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does`), which
also keeps the tool family together.
2026-10-12: a native Italian speaker read the catalog and fixed mistranslations (Trap, HDR
Toning, Display Text, Vine Color, Override Action "Save" Commands) and unnatural phrasing (Bevel &
Emboss, East Asian / Middle Eastern Features, linked smart objects, bit depth labels).
+7 -5
View File
@@ -1,10 +1,10 @@
# Localization parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Spanish dynamic labels; previously minor: Serbian catalog) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (Russian dynamic labels; previously minor: Spanish dynamic labels) · **Target:** Adobe Photoshop 2026
Per-language status of PhotoCraft's UI. How catalogs work and how to add one:
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md),
[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md).
[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md), [Russian](localization-ru.md).
**How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key
universe: `tl!("…")` literals and `trn(` plurals in `ui-egui` (tests excluded), menu-catalog
@@ -33,7 +33,7 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde
| English | `en` | 1,608 / 1,608 (100%) | source | all | Latin | – | full | 0 h |
| Simplified Chinese | `zh-hans` | 1,608 / 1,608 (100%) | 2,366 | dialogs, tooltips yes; engine messages English | CJK fonts (lazy loader), IME preedit/commit, vertical type | no | partial | 8–12 h |
| Spanish | `es` | 1,844 / 1,844 (100%) | 4,010 | as above; complete dynamic UI key set | Latin | no | partial | 6–10 h |
| Hindi | `hi` | 0 (0%) | – | – | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | none | 20–30 h |
| Hindi | `hi` | partial | 448 | Top menus, toolbox tools, camera raw, dialogs, plurals | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | partial | 15–20 h |
| Arabic | `ar` | 0 (0%) | – | – | Arabic shaping and bidi in the text engine, Arabic fallback font; RTL shell layout but not inside panels, and UI labels in LTR word order (DIST-220-4), paragraph direction has no UI (TYPE-218-2), layer names as tofu (#1408, #1693) | no | none | 40–60 h |
| French | `fr` | 1,608 / 1,608 (100%) | 2,766 | as above | Latin | no | partial | 6–10 h |
| Portuguese (Brazil) | `pt-br` | 1,608 / 1,608 (100%) | 2,788 | as above | Latin; every `pt` locale uses it | no | partial | 6–10 h |
@@ -46,8 +46,8 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde
## Other shipped languages
8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional
Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522),
Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,008; also every dynamic label the
Chinese `zh-hant` (2,329 rows), Russian `ru` (4,227 keys; also every dynamic label the
other catalogs translate, [notes](localization-ru.md)), Ukrainian `uk` (3,375), Czech `cs` (3,522),
Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the
other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr`
(3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every
@@ -87,3 +87,5 @@ engine localization or native review is complete.
| 2026-10-12 | minor | Spanish: 1,276 runtime-only labels added (filter and adjustment parameters, Camera Raw, panels, dialogs and status wrappers); generated-label coverage test added |
| 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed |
| 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs |
| 2026-10-13 | minor | Italian: the `Screen` blend mode read *Schermo* (the display) and is now *Scherma*; the Dodge/Burn/Sponge tool names were already Adobe's (*Scherma*, *Brucia*, *Spugna*) and stay. Pinned by a test |
| 2026-10-18 | minor | Russian: 1,229 dynamic labels added (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs) with a terminology note ([localization-ru](localization-ru.md)) |
+75
View File
@@ -0,0 +1,75 @@
# Russian terminology and dynamic UI labels
This note goes with the completion of the Russian catalog (`crates/ui-egui/src/i18n/ru.tsv`).
The catalog already covered every key `cargo xtask i18n-coverage` measures, but it lacked the
labels that only exist at runtime and that other catalogs (Italian, Serbian, Czech, Hungarian)
already translate: filter and adjustment parameter names, Camera Raw panels, lighting effects,
History / Layers / paths / channels menu labels, metadata fields, status messages, System Info
and dialog text. In Russian those showed in English.
They are now translated, reusing the vocabulary the catalog already uses elsewhere so the same
term reads the same everywhere. Translations are written from the meaning of PhotoCraft's English
labels; no proprietary translation resources, fonts, screenshots or user documents are included.
## Register and style
Commands are imperatives in the second person plural (*Сохранить*, *Удалить*, *Заново*);
where the catalog already uses a noun it is kept (*Уровни…*, *Кривые…*). Sentence case after the
first word, as in the rest of the catalog. Ellipses on dialog commands are preserved (`Ветер…`
→ `Ветер…`). Decimals use a comma (the file format's own labels keep their dots, e.g.
`Landscape, 10 x 8` stays `Альбомная, 10 x 8`). Latin product and technology names are not
translated (PhotoCraft, Camera Raw, RGB, CMYK, Lab, LUT, WebP); Camera Raw keeps `.xmp`; the
`HDR`, `HSL`, `HSB` and `W3C` acronyms and the `sGray`/`Pro Photo` identifiers stay Latin.
## Terminology
Existing choices were followed rather than re-invented, so the new rows use:
| English source | Russian | Note |
|---|---|---|
| Highlight(s) | Света | already in the catalog (`Highlights` → `Света`) |
| Midtone(s) | Полутона | already in the catalog |
| Shadow(s) | Тени | pairs with *Света* |
| Feather | Растушёвка | never *перо* |
| Fuzziness | Разброс | Color Range's tolerance |
| Dither | Дизеринг | |
| Pattern | Узор | also in *Узорный штамп* |
| Preset | Набор | *Набор кистей*, *набор инструментов* |
| Guide | Направляющая | *Схема направляющих* |
| Artboard | Монтажная область | |
| Workspace | Рабочая среда | |
| Stroke (contour) | Обводка | painterly strokes stay *штрихи*/*мазки* |
| Stroke (paint param) | Штрихи / мазки | `Strokes` → `Штрихи` |
| Amount | Величина | `Штрихи`, never *количество*, when it is a strength |
| Fill Opacity | Непрозрачность заливки | distinct from layer *Непрозрачность* |
| Feather Radius | Радиус растушёвки | |
| Sharpen | Резкость | `Smart Sharpen` → *Умная резкость*, `Unsharp Mask` → *Контурная резкость* |
| Noise | Шум | `Reduce Noise` → *Уменьшить шум* |
| Content-Aware | С учётом содержимого | |
| Camera Raw | Camera Raw | the panel name stays Latin (as German/Spanish do; Greek translates it) |
| Place Embedded / Linked | Поместить встроенный / связанный | |
| Rasterize | Растрировать | |
## Native review
The review was AI-assisted and each row was checked against the meaning of PhotoCraft's own
English label. A review by a native speaker is still welcome.
## Not covered
Engine error and status messages, user document, layer and preset names, and automation command
IDs stay in English, as in every language ([`localization-parity.md`](localization-parity.md)).
A few dynamic labels the other catalogs carry are not used by the current code; they are
translated to stay aligned with the other catalogs.
## Validation
- `russian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`)
enumerates every filter and adjustment parameter, symbolic choice and gallery name and requires
a Russian row for each, like the Korean and Italian tests.
- `russian_dynamic_status_hardware_and_dialog_labels_are_translated` (`crates/ui-egui/src/i18n/mod.rs`)
covers the rasterize prompts, hardware status lines, composed status templates and a sample of
filter labels.
- `cargo test -p photocraft-ui-egui --lib`: 2,998 passed, 0 failed.
- `cargo xtask i18n-coverage`: `ru` remains at 100% of the measured keys.
- The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=ru` renders the dialogs with Russian
labels, without missing glyphs or clipped text.
+1
View File
@@ -28,6 +28,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
| Magyar | `hu` | one form |
| Tiếng Việt | `vi` | one form |
| Srpski | `sr` | one / few / many (teens use many) |
| हिन्दी | `hi` | one / other |
Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend
mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is
+1 -1
View File
@@ -215,7 +215,7 @@ Unread: `general.color_picker`, `general.zoom_resizes_windows`, `workspace.auto_
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|
| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text) |
| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip; a Type tool click on the work path or the active shape layer's path starts path type at the point clicked (type.attachToPath at, path-type pointer over a path). Missing: vertical type on a path, dragging the start/flip, the caret and selection following the curve, and PSD path text (it saves as point text) |
| TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI |
| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) |
| TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet |
+2
View File
@@ -213,6 +213,7 @@ Paragraph; the rest open as in-app floating windows.
| Behaviour | Status | Evidence |
|---|---|---|
| Character and Paragraph controls without a type target | done | #5556; persistent Type tool defaults feed new point, box and mask text; existing text still edits in its selected scope (`type_tool.rs`, `type_tool_tests.rs`) |
| Panel groups float and tear off into OS windows | missing | UI-217-5, #1828, #1213 |
| Drag panels to reorder and regroup | partial | #2272, #2305 |
| Docked resize pushes the neighbour | partial | #2573 |
@@ -242,6 +243,7 @@ Paragraph; the rest open as in-app floating windows.
| 2026-10-15 | minor | Shape Density and Feather row added as done (TOOL-214-12, #5846) |
| 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) |
| 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial |
| 2026-10-14 | minor | Character and Paragraph panels edit persistent defaults without a type target (#5556); no percentage or estimate changed |
| 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed |
| 2026-10-13 | minor | Smart object perspective (non-affine) corrected to done: #426 landed four-corner placements with PSD round trip; scorecard FILE-215-6 flipped |
| 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues |
+3 -3
View File
@@ -9,14 +9,14 @@ id = "TYPE-218-1"
target = "Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip"
status = "partial"
issue = 218
note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text)"
note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip; a Type tool click on the work path or the active shape layer's path starts path type at the point clicked (type.attachToPath at, path-type pointer over a path). Missing: vertical type on a path, dragging the start/flip, the caret and selection following the curve, and PSD path text (it saves as point text)"
[[item]]
id = "TYPE-218-2"
target = "Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret"
status = "missing"
status = "partial"
issue = 218
note = "Engine has TextDirection (text.rs:155); no UI"
note = "Paragraph section (Window > Paragraph and Properties) and the options bar have Auto / Left-to-right / Right-to-left buttons setting the paragraph direction through type.setStyle; the options bar's choice is also the direction new type starts with (type_tool.rs direction_items; paragraph_direction_buttons_set_and_show_the_direction, options_bar_direction_sets_selected_layers_and_new_type). Still missing: visual RTL caret (the arrow keys move in logical order)"
[[item]]
id = "TYPE-218-3"