diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1455316ec5..d8244b4fec 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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) | | 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 | | 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 | (`icons/.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 | (`icons/.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 | 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 | diff --git a/ROADMAP.md b/ROADMAP.md index f41b71bfc9..ca3bd5cbb6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/assets/icons/text-warp.svg b/assets/icons/text-warp.svg new file mode 100644 index 0000000000..03f30ed87b --- /dev/null +++ b/assets/icons/text-warp.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/contributors/people.toml b/contributors/people.toml index c54a3c4bb1..b5084209a4 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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 diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 92188f219f..24bc8f861d 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -1190,13 +1190,13 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { 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 { { 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(()) })?; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index c38608224c..34a69d900a 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1089,7 +1089,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "plugIns.allowScriptsToConnect", "plugIns.generatorEnabled", "type.textEngine", - "type.recentFonts", "enhancedControls.scrubbySliderAcceleration", "rawDefaults.colorSpace", "rawDefaults.bitDepth", diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 44b6a98be4..820338bf2d 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -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(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document, &mut Option) -> Result) -> Result { +/// `f` gets the document's resolution (dpi). +fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32, &mut Option) -> Result) -> Result { 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(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 { } }; 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 { 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 { 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(()) diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index c431a3081d..fd54db1fed 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -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(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result) -> Result { +/// Edits a type layer as one history step and re-renders it. `f` gets the document's resolution +/// (dpi). +fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32) -> Result) -> Result { 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(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 // ---------- point ⇄ paragraph ---------- fn to_paragraph(s: &mut Session, p: &Value) -> Result { - 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 { 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 { } fn to_point(s: &mut Session, p: &Value) -> Result { - 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 { 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 { 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 { fn update_all(s: &mut Session) -> Result { let mut n = 0; s.edit("Update All Text Layers", |doc, _| { - let snapshot = doc.clone(); - let ids: Vec = 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 = 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, 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, 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 { "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 ), diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index ae913555fd..d1bc3c39f1 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -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. diff --git a/crates/engine/src/type_spell_cmds.rs b/crates/engine/src/type_spell_cmds.rs index 12a63a364c..915b21229a 100644 --- a/crates/engine/src/type_spell_cmds.rs +++ b/crates/engine/src/type_spell_cmds.rs @@ -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) -> EngineError { @@ -102,10 +102,10 @@ fn change(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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(()) })?; diff --git a/crates/engine/src/type_styles_cmds.rs b/crates/engine/src/type_styles_cmds.rs index 93b4261a00..4cbb2092dc 100644 --- a/crates/engine/src/type_styles_cmds.rs +++ b/crates/engine/src/type_styles_cmds.rs @@ -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 = 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 { } 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 = 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 { } } if hit { - refresh(&snapshot, t); + refresh_at(dpi, format, t); n += 1; } } diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs index 0a49d780d5..0560a9d593 100644 --- a/crates/engine/src/variables_cmds.rs +++ b/crates/engine/src/variables_cmds.rs @@ -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)) => { diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 57d6081c66..31de399b18 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -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})) diff --git a/crates/engine/tests/type_postscript_font.rs b/crates/engine/tests/type_postscript_font.rs new file mode 100644 index 0000000000..06171d7d84 --- /dev/null +++ b/crates/engine/tests/type_postscript_font.rs @@ -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 { + 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)); +} diff --git a/crates/text/src/craft_fonts.rs b/crates/text/src/craft_fonts.rs index c1c1949ea9..ed24a42035 100644 --- a/crates/text/src/craft_fonts.rs +++ b/crates/text/src/craft_fonts.rs @@ -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=` -//! (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 { diff --git a/crates/text/src/font_names_tests.rs b/crates/text/src/font_names_tests.rs index 0de4bf2445..8b8bcd419c 100644 --- a/crates/text/src/font_names_tests.rs +++ b/crates/text/src/font_names_tests.rs @@ -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) -> crate::CraftFont { + crate::CraftFont { family, style: "Regular", scripts, bytes: bytes.leak() } +} + +/// A TrueType collection of single-face fonts. +fn collection(faces: &[Vec]) -> Vec { + 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::>()); + } +} + +/// 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")); +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 720d2b77eb..893fd29ed8 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -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) -> Vec { - 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 { - self.register_blob(Blob::new(Arc::new(bytes))) + self.register_blob(Blob::new(Arc::new(bytes)), None) } - fn register_blob(&mut self, blob: Blob) -> Vec { - 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, family: Option<&str>) -> Vec { + 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) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index af4de19f52..929f8841cc 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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 diff --git a/crates/text/src/raster.rs b/crates/text/src/raster.rs index bca52e38cf..4f32e9941e 100644 --- a/crates/text/src/raster.rs +++ b/crates/text/src/raster.rs @@ -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 { + 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 { + /// 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> { 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}"); } diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 7128085dc2..fe32982d9d 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -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::() 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> { + 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 = 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()); + } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 9ae94e4282..1f9eac5c17 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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::>()); + // 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 diff --git a/crates/text/src/warp.rs b/crates/text/src/warp.rs index f148a18e1b..439fc453bf 100644 --- a/crates/text/src/warp.rs +++ b/crates/text/src/warp.rs @@ -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 { + 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")); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index df5d3336c0..7ec79cd6ba 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 2a3c6adc31..4c6813b58c 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -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] diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index 93a609f946..0e9c354446 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -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. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ab4c999af1..7e1f044d87 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1ccb444e54..315f7ebb55 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0451778f60..c38c837480 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 Κατεύθυνση παραγράφου από δεξιά προς τα αριστερά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c9a138a313..025eb3e195 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d5df62d148..af00250e5e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1468,6 +1468,7 @@ Set shape fill type Définir le type de remplissage de la forme Set shape stroke type Définir le type de contour de la forme Set the text color Définir la couleur du texte + Create warped text Créer un texte déformé Shadows Ombres Shape: Forme : Sharpening Netteté @@ -3349,3 +3350,1039 @@ curves Smooth Lisser Resize Windows to Fit Redimensionner les fenêtres EXR EXR + (none) (aucun) + 32-bit Preview Options Options d'aperçu 32 bits + AI Integrations Intégrations d'IA + About {app} À propos de {app} + Accented Edges Contours accentués + Action playback failed: {error} Échec de la lecture de l'action : {error} + Actual Pixels Pixels réels + Add Mask Ajouter un masque + Add Noise Ajout de bruit + Added swatch {name} Nuance {name} ajoutée + Adjust Interval For Loops Ajuster l'intervalle pour les boucles + Adjustment Layers Calques d'ajustement + Adjustment Properties Propriétés de l'ajustement + Adobe-rgb-compat Adobe RGB (compatible) + Adwaita (Dark) Adwaita (sombre) + Adwaita (Light) Adwaita (clair) + After Après + All shortcuts reset to Photoshop defaults. Tous les raccourcis ont été réinitialisés aux valeurs par défaut de Photoshop. + Ambience Ambiance + Ambient Ambiante + Amplitude Max Amplitude max. + Amplitude Min Amplitude min. + Angled Strokes Traits obliques + Anisotropic Anisotrope + Apply Data Set Appliquer un jeu de données + Apply Image Appliquer une image + Apply Mask Appliquer le masque + Apply to All Appliquer à tout + Apply {option}: {state} Appliquer {option} : {state} + Arbitrary Angle personnalisé + Arc Lower Arc inférieur + Arc Upper Arc supérieur + Are you sure you want to quit? Voulez-vous vraiment quitter ? + Are you sure? Êtes-vous sûr ? + Area Aire + Around Center Autour du centre + Arrangement Disposition + Arrows Flèches + Artboard from Group Plan de travail d'après un groupe + Artboard from Layers Plan de travail d'après des calques + Artboards Plans de travail + Artboards to Files Plans de travail en fichiers + Artboards to PDF Plans de travail en PDF + Artistic Artistique + Ask On Mismatch Demander en cas de non-concordance + Ask On Missing Demander en cas d'absence + Ask On Paste Demander au collage + Assign Profile Attribuer un profil + At À + Auto Scale Mise à l'échelle auto + Auto-Align Layers Alignement automatique des calques + Auto-Blend Layers Fusion automatique des calques + Autosave failed: {error} Échec de l'enregistrement automatique : {error} + Back Retour + Backend: {backend} ({type}) Moteur : {backend} ({type}) + Background Level Niveau d'arrière-plan + Bamboo Bambou + Bas Relief Bas-relief + Base Tree Type Type d'arbre de base + Basic Latin Latin de base + Batch Traitement par lots + Before Avant + Berries Baies + Best Optimale + Black Clip Écrêtage des noirs + Black Intensity Intensité du noir + Blade Curvature Courbure des lames + Blast Rafale + Bleach Bypass Sans blanchiment + Blend Clipped Layers as Group Fusionner les calques découpés en tant que groupe + Blend Interior Effects as Group Fusionner les effets internes en tant que groupe + Blend Text Gamma Gamma de fusion du texte + Blending Mode Mode de fusion + Bloat Dilatation + Blocks Blocs + Blue Yellow Bleu/Jaune + Border Bordure + Border Thickness Épaisseur de la bordure + Box Blur Flou par boîte + Bpc Compensation du point noir + Branches Color Couleur des branches + Branches Height Hauteur des branches + Branches Thickness Épaisseur des branches + Breeze Dark Breeze sombre + Breeze Light Breeze clair + Brick Brique + Bristle Detail Détail des poils + Browse Parcourir + Brush Detail Détail du pinceau + Brush Strokes Traits de pinceau + Brush Type Type de pinceau + Burlap Toile de jute + Calculations Calculs + Camera Raw Camera Raw + Camera Raw Filter Filtre Camera Raw + Cancel transform Annuler la transformation + Candle Light Bougie + Canvas Size Taille du canevas + Cell Size Taille des cellules + Center X Centre X + Center Y Centre Y + Chalk Area Zone de craie + Changes Saved Modifications enregistrées + Changes have not been saved. Les modifications n'ont pas été enregistrées. + Change… Modifier… + Channel1 Canal 1 + Channel2 Canal 2 + Channel3 Canal 3 + Channel4 Canal 4 + Charcoal Area Zone de fusain + Charcoal Thickness Épaisseur du fusain + Check for Updates Rechercher des mises à jour + Choose Folder Choisir un dossier + Circle Cercle + Circularity Circularité + Cleanliness Propreté + Clear Amount Zone sans lueur + Clear Selection Effacer la sélection + Clipping Mask Masque de découpe + Clipping Path… Tracé de détourage… + Clone Cloner + Clone source set at {x}, {y} Source de clonage définie en {x}, {y} + Close Window Fermer la fenêtre + Clumped Aggloméré + Coarse Dots Points grossiers + Coated-cmyk CMJN papier couché + Color Decontaminate Décontaminer les couleurs + Color Halftone Trame de demi-teintes couleur + Color Indicates Masked Areas La couleur indique les zones masquées + Color Indicates Selected Areas La couleur indique les zones sélectionnées + Color Lookup Tables Tables de correspondance des couleurs + Color Picker Sélecteur de couleurs + Color Picker (Background Color) Sélecteur de couleurs (Couleur d'arrière-plan) + Color Picker (Custom Canvas Color) Sélecteur de couleurs (Couleur personnalisée du canevas) + Color Picker (Foreground Color) Sélecteur de couleurs (Couleur de premier plan) + Color Settings Paramètres de couleur + Color Swatches Nuancier + Color Table Table des couleurs + Color management: {error} Gestion des couleurs : {error} + Colored Pencil Crayon de couleur + Commit transform Valider la transformation + Compare Comparer + Conditional Mode Change Changement de mode conditionnel + Confirm Confirmer + Constraints Contraintes + Continue Continuer + Contrasty Contrasté + Convert Convertir + Convert to Alpha Channel Convertir en canal alpha + Convert to Linked Convertir en objet lié + Convert to Profile Convertir en profil + Cool Froid + Cooling L B B Refroidissement LBB + Cooling80 Refroidissement 80 + Cooling82 Refroidissement 82 + Copy Complete Stroke Copier le contour complet + Copy Fill Copier le remplissage + Correct C A Corriger l'aberration chromatique + Correct Distortion Corriger la distorsion + Correct Vignette Corriger le vignettage + Could not rename the action: {error} Impossible de renommer l'action : {error} + Couldn't open {name}: {error} Impossible d'ouvrir {name} : {error} + Couldn't save preferences: {error}. Retrying… Impossible d'enregistrer les préférences : {error}. Nouvelle tentative… + Crack Brightness Luminosité des fissures + Crack Depth Profondeur des fissures + Crack Spacing Espacement des fissures + Craquelure Craquelure + Create By Créer par + Create Vector Mask Créer un masque vectoriel + Create… Créer… + Cross Process Traitement croisé + Crosshatch Hachures croisées + Crystallize Cristallisation + Currency Devise + Curve Darks Courbe : tons sombres + Curve Highlights Courbe : hautes lumières + Curve Lights Courbe : tons clairs + Curve Shadows Courbe : ombres + Curve Splits Courbe : points de séparation + Custom Branch Color Couleur de branche personnalisée + Custom Pixel Aspect Ratio Format des pixels personnalisé + Cutout Découpage + Cyrillic Cyrillique + Daisies Pâquerettes + Dark Gray Gris foncé + Dark Intensity Intensité des tons foncés + Dark Rough Foncé rugueux + Dark Strokes Traits sombres + Darken Only Assombrir seulement + Darkness Obscurité + Data Données + Data Set: {name} ({index}/{total}) Jeu de données : {name} ({index}/{total}) + Data Sets as Files Jeux de données en fichiers + Date Date + Date and Time Date et heure + Day For Night Nuit américaine + De-Interlace Désentrelacer + Deep Profond + Deep Blue Bleu profond + Deep Emerald Émeraude profond + Deep Red Rouge profond + Deep Yellow Jaune profond + Default Foreground and Background Colors Couleurs par défaut du premier plan et de l'arrière-plan + Default Leaves Feuilles par défaut + Default Photoshop Size Taille Photoshop par défaut + Defaults Valeurs par défaut + Define Brush Preset Définir un préréglage de pinceau + Define Custom Shape Définir une forme personnalisée + Define Pattern Définir un motif + Definition Définition + Defringe Supprimer les franges + Degrees Degrés + Delete Channel Supprimer le canal + Delete Mask Supprimer le masque + Delete… Supprimer… + Depth Map Carte de profondeur + Depth Mode Mode de profondeur + Diameter Diamètre + Diffuse Diffuser + Diffuse Glow Lueur diffuse + Direction Balance Équilibre des directions + Disable Désactiver + Disable Mask Désactiver le masque + Disable Symmetry Path Désactiver le tracé de symétrie + Disabled Désactivé + Discard Changes Ignorer les modifications + Displace Déplacement + Display Affichage + Display profile not used Profil d'affichage non utilisé + Display profiles: {reason} Profils d'affichage : {reason} + Display-p3 Display P3 + Distortion Distorsion + Distribution Distribution + Do you want to save you made to “{name}” before quitting? Voulez-vous enregistrer les modifications apportées à « {name} » avant de quitter ? + Documentation Documentation + Documents Documents + Dot Point + Double Line Double ligne + Draft Brouillon + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Tracez un cadre de recadrage · faites glisser à l'intérieur pour le déplacer · les bords le redimensionnent ({ratio} proportions, {centre} centre) · Espace le déplace pendant le tracé · {commit} valide · Échap annule + Drag: draw freehand · Release on the start point: close Glisser : tracer à main levée · Relâcher sur le point de départ : fermer + Driver: {driver} Pilote : {driver} + Dry Brush Pinceau sec + Duplicate Channel Dupliquer le canal + Duplicate Layers Dupliquer les calques + Duplication Duplication + Dust & Scratches Poussière et rayures + Edge Brightness Luminosité du contour + Edge Extension Extension du contour + Edge Fidelity Fidélité des contours + Edge Intensity Intensité du contour + Edge Only Contours seulement + Edge Simplicity Simplicité des contours + Edge Thickness Épaisseur du contour + Edge Width Largeur du contour + Edit Toolbar Modifier la barre d'outils + Edit… Modifier… + Elevation Élévation + Eliminate Éliminer + Enable Activer + Enable Mask Activer le masque + End X Fin X + End Y Fin Y + Enlarged Agrandi + Even Fields Trames paires + Exit Quick Mask Quitter le mode masque rapide + Export As Exporter sous + Export Contents Exporter le contenu + Export Image Exporter l'image + Export Options Options d'exportation + Export Preferences Préférences d'exportation + Export Settings Paramètres d'exportation + Export to Exporter vers + Export/Import Presets Exporter/importer des préréglages + Exported contents to {path} Contenu exporté vers {path} + Exported presets to {path} Préréglages exportés vers {path} + Exported {path} {path} exporté + Exported {path} ({size}) {path} exporté ({size}) + Exporting… Exportation… + Extrude Extruder + Faded Film Film délavé + Failed Échec + Failed to open file Échec de l'ouverture du fichier + Failed to save file Échec de l'enregistrement du fichier + Fallback: {reason} Repli : {reason} + Feather Radius Rayon d'adoucissement + Fiber Length Longueur des fibres + Fibers Fibres + Field Blur Flou de champ + File Format Format de fichier + File Info Informations sur le fichier + File Name Nom du fichier + File Size Taille du fichier + File not found Fichier introuvable + Filename Nom de fichier + Fill Color Couleur de remplissage + Fill Path… Remplir le tracé… + Film Grain Grain de film + Filter Gallery Galerie de filtres + Find Rechercher + Find and Replace Text Rechercher et remplacer du texte + Fine Fine + Fine Dots Points fins + Finish Terminer + Fit Image Adapter l'image + Fit to Window Adapter à la fenêtre + Flame Flamme + Flame Bottom Alignment Alignement du bas de la flamme + Flame Lines Lignes de flamme + Flame Shape Forme de la flamme + Flame Style Style de la flamme + Flame Type Type de flamme + Flat Plat + Flat Shading Ombrage plat + Flower Color Couleur des fleurs + Focal Distance Distance de mise au point + Focus Mise au point + Focus Area Zone de mise au point + Foreground Level Niveau de premier plan + Forward Warp Déformation vers l'avant + Frame Cadre + Free Transform Path Transformation libre du tracé + Freeze Lasso Lasso de figeage + Freeze Mask Masque de figeage + Fresco Fresque + From Left À partir de la gauche + From Right À partir de la droite + Frosted Dépoli + Full Screen Plein écran + Fullscreen Plein écran + Fuzziness Tolérance + GPU backend preference: {backend} Préférence de moteur GPU : {backend} + GPU device was lost; using the CPU renderer. Le périphérique GPU a été perdu ; utilisation du rendu CPU. + GPU error; using the CPU renderer. Erreur GPU ; utilisation du rendu CPU. + GPU retry scheduled Nouvelle tentative GPU planifiée + Gamma Gamma + Gaussian Blur Flou gaussien + General Blending Fusion générale + Generators Générateurs + Generic Générique + Glass Verre + Global Light Lumière globale + Gloss Brillant + Glow Amount Quantité de lueur + Glow Brightness Luminosité de la lueur + Glow Size Taille de la lueur + Glowing Edges Contours lumineux + Good Bonne + Grade Balance Balance de l'étalonnage + Grade Blending Fusion de l'étalonnage + Grade Global Étalonnage global + Grade Highlights Étalonnage des hautes lumières + Grade Midtones Étalonnage des tons moyens + Grade Shadows Étalonnage des ombres + Grain Amount Quantité de grain + Grain Roughness Rugosité du grain + Grain Size Taille du grain + Grain Type Type de grain + Graininess Grain + Grainy Dots Points granuleux + Graphics adapter Carte graphique + Graphics adapter: {adapter} Carte graphique : {adapter} + Gray Value (Maximum) Valeur de gris (maximum) + Gray Value (Mean) Valeur de gris (moyenne) + Gray Value (Median) Valeur de gris (médiane) + Gray Value (Minimum) Valeur de gris (minimum) + Gray-gamma-2.2 Gris gamma 2,2 + Greek Grec + Grid view Affichage en grille + Group from Layers Groupe d'après des calques + Groups Groupes + Grout Width Largeur du joint + Guide Layout… Disposition des repères… + HDR Toning Tonalité HDR + Hearts Cœurs + Heptagon Heptagone + Hexagon Hexagone + Hidden Masqué + Hide Grid Masquer la grille + Hide Others Masquer les autres + Hide {app} Masquer {app} + High Pass Passe-haut + Highlight Hautes lumières + Highlight Amount Quantité des hautes lumières + Highlight Area Zone des hautes lumières + Highlight Radius Rayon des hautes lumières + Highlight Strength Intensité des hautes lumières + Highlight Tone Amplitude tonale des hautes lumières + Horizontal Only Horizontal seulement + Hotspot Point chaud + Hsb TSL + Hsl TSI + Hsl Hue Teinte TSI + Hsl Lum Luminance TSI + Hsl Sat Saturation TSI + Image Balance Équilibre de l'image + Image Processor Processeur d'images + Image compositor: {processor} Compositeur d'images : {processor} + Import Brushes Importer des pinceaux + Import Image Importer une image + Import Options Options d'importation + Import Photoshop brushes (.abr) Importer des pinceaux Photoshop (.abr) + Import Settings Paramètres d'importation + Importing {what} from {file}… Importation de {what} depuis {file}… + Importing… Importation… + In Black Noir d'entrée + In White Blanc d'entrée + Infinite Infini + Information Informations + Ink Outlines Contours à l'encre + Input Entrée + Input Levels Niveaux d'entrée + Input Mode Mode d'entrée + Install Plug-in Installer une extension + Integrated Density Densité intégrée + Intensity Intensité + Intent Intention de rendu + Interpret Footage Interpréter le métrage + Interval Intervalle + Invalid panel layout; restored the default dock Disposition des panneaux non valide ; l'ancrage par défaut a été rétabli + Invert Mask Inverser le masque + Invert Selection Inverser la sélection + Iris Blur Flou d'iris + Ivy Lierre + Jag Dentelé + Join the ArtCraft Discord Rejoindre le Discord d'ArtCraft + Join the ArtCraft Discord ({url}) Rejoindre le Discord d'ArtCraft ({url}) + Kernel Noyau + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Crénage : Métriques, Optique ou une valeur en 1/1000 em (Alt+←/→ au point d'insertion) + Keyboard Clavier + Label Étiquette + Latin 1 Latin 1 + Latin Extended-A Latin étendu A + Latin Extended-B Latin étendu B + Layer Comps to Files Compositions de calques en fichiers + Layer Content Options Options de contenu du calque + Layer From Background Calque d'après l'arrière-plan + Layer Mask Hides Effects Le masque de calque masque les effets + Layer Properties Propriétés du calque + Layer Styles Styles de calque + Layer from Background Calque d'après l'arrière-plan + Layers to Files Calques en fichiers + Leaf Color Couleur de la feuille + Learn More En savoir plus + Leaves Amount Quantité de feuilles + Leaves Color Couleur des feuilles + Leaves Size Taille des feuilles + Left Diagonal Diagonale gauche + Legacy Ancienne version + Length Longueur + Lens Blur Flou d'objectif + Lens Correction Correction de l'objectif + Lens Flare Reflet d'objectif + Less Moins + Level Based Selon les niveaux + Library Bibliothèque + Light Dark Balance Équilibre clair/foncé + Light Direction Direction de la lumière + Light Intensity Intensité de la lumière + Light Rough Clair rugueux + Light Type Type de lumière + Light X Lumière X + Light Y Lumière Y + Light Z Lumière Z + Lighten Grout Éclaircir le joint + Lighten Only Éclaircir seulement + Lighting Éclairage + Lighting Effects Effets d'éclairage + Linear Dodge Densité linéaire − + Linear-srgb sRVB linéaire + Link Lier + Liquify Liquéfier + List Liste + List view Affichage en liste + Live Preview Aperçu en direct + Load 3D LUT Charger une LUT 3D + Load Files into Stack Charger des fichiers dans une pile + Loading… Chargement… + Lock All Tout verrouiller + Lock Image Pixels Verrouiller les pixels de l'image + Lock Position Verrouiller la position + Lock Transparent Pixels Verrouiller les pixels transparents + Locked Verrouillé + Long Lines Lignes longues + Long Strokes Traits longs + Lower Inférieur + Lum Lum. + Lut LUT + Make Selection… Créer une sélection… + Make Symmetry Path Créer un tracé de symétrie + Make Work Path… Créer un tracé de travail… + Manual Manuel + Map Document Document de carte + Map Layer Calque de carte + Map Path Chemin de la carte + Mask Incomplete Blocks Masquer les blocs incomplets + Mask None Ne rien masquer + Masks Masques + Match Color Faire correspondre la couleur + Math Symbols Symboles mathématiques + Max Offset Décalage max. + Max Radius Rayon max. + Medium Dots Points moyens + Medium Gray Gris moyen + Medium Lines Lignes moyennes + Medium Strokes Traits moyens + Merge Channels… Fusionner les canaux… + Merge Spot Channel Fusionner le canal de ton direct + Merge to HDR Pro Fusionner en HDR Pro + Metallic Métallique + Mezzotint Manière noire + Middle Milieu + Midtone Contraste des tons moyens + Monitor Profile Profil du moniteur + Monitor profile in use on {display}: {profile} Profil du moniteur utilisé sur {display} : {profile} + Monitor profile in use: {profile} Profil du moniteur utilisé : {profile} + Monitor profile on {display} (main window): {profile} Profil du moniteur sur {display} (fenêtre principale) : {profile} + Monitor profile on {display}: {profile} Profil du moniteur sur {display} : {profile} + Monitor profile: {profile} Profil du moniteur : {profile} + Mono Contrast Contraste mono + Monochromatic Monochromatique + More Plus + Mosaic Mosaïque + Mosaic Tiles Carreaux de mosaïque + Motion Blur Flou de mouvement + Mouse Souris + Move Layer Déplacer le calque + Movie Prime Focale fixe cinéma + Multiple Flames Along Path Plusieurs flammes le long du tracé + Multiple Flames One Direction Plusieurs flammes dans une direction + Multiple Flames Path Directions Plusieurs flammes selon les directions du tracé + Multiple Flames Various Length Plusieurs flammes de longueurs variées + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Le glisser-déposer natif de fichiers n'est pas encore pris en charge sous Wayland. Utilisez Fichier › Ouvrir, ou lancez PhotoCraft sous XWayland avec `WAYLAND_DISPLAY= photocraft`. + Neon Glow Lueur néon + Neutralize Neutraliser + New Brush Preset Nouveau préréglage de pinceau + New Channel… Nouveau canal… + New Guide Nouveau repère + New Guide Layout Nouvelle disposition de repères + New Spot Channel… Nouveau canal de ton direct… + New Video Layer from File Nouveau calque vidéo à partir d'un fichier + New Window Nouvelle fenêtre + New document Nouveau document + Next Suivant + No Document Aucun document + No Image Aucune image + No Layers Aucun calque + No Results Aucun résultat + No Results Found Aucun résultat trouvé + No Selection Aucune sélection + No custom shape "{name}": pick one in the options bar or Window › Shapes Aucune forme personnalisée « {name} » : choisissez-en une dans la barre d'options ou via Fenêtre › Formes + No layers Aucun calque + No recent files Aucun fichier récent + No selection Aucune sélection + Noise Color Bruit de couleur + Noise Color Detail Détail du bruit de couleur + Noise Luminance Bruit de luminance + Noise Luminance Detail Détail du bruit de luminance + Nothing to Redo Rien à rétablir + Nothing to Undo Rien à annuler + Nothing to paste: the clipboard holds no image Rien à coller : le presse-papiers ne contient aucune image + Number Of Levels Nombre de niveaux + Numbers Nombres + Ocean Ripple Ondulation océanique + Octagon Octogone + Odd Fields Trames impaires + Oil Paint Peinture à l'huile + On Activé + One Flame Along Path Une flamme le long du tracé + Open As Ouvrir en tant que + Open File Ouvrir un fichier + Open Folder Ouvrir un dossier + Open Image Ouvrir une image + Open Recent is unavailable on the web La commande Ouvrir un fichier récent n'est pas disponible sur le Web + Opened {url} {url} ouvert + Opening Ouverture + Operation canceled Opération annulée + Operation cancelled Opération annulée + Operation completed Opération terminée + Operation failed Échec de l'opération + Out Black Noir de sortie + Out From Center Du centre vers l'extérieur + Out White Blanc de sortie + Out of memory Mémoire insuffisante + Output Levels Niveaux de sortie + Oval Ovale + Overlay Edge Superposition des contours + Package Rassembler + Page Setup… Mise en page… + Paint Daubs Barbouillage + Palette Knife Couteau à palette + Panel Panneau + Paper Brightness Luminosité du papier + Parallel Parallèle + Paste Complete Stroke Coller le contour complet + Paste Fill Coller le remplissage + Patchwork Patchwork + Path Blur Flou de tracé + Paths to Illustrator Tracés vers Illustrator + Pattern Type Type de motif + Pencil Width Largeur du crayon + Pentagon Pentagone + Percentage Pourcentage + Perimeter Périmètre + Permission denied Autorisation refusée + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft continue de réessayer ; tant qu'un enregistrement n'a pas abouti, les modifications des préférences seront perdues à la fermeture. + Photomerge Photomerge + Picture Frame Cadre d'image + Pinch Pincement + Pins Épingles + Place Embedded Placer incorporé + Place Linked Placer lié + Place Scale Marker Placer un repère d'échelle + Plane Plan + Planes Plans + Plastic Wrap Film plastique + Platform: {os} {arch} Plateforme : {os} {arch} + Please wait… Veuillez patienter… + Point Point + Point Curve Courbe de points + Point Curve Blue Courbe de points bleue + Point Curve Green Courbe de points verte + Point Curve Red Courbe de points rouge + Pointed Pointu + Pointillize Pointillisme + Polar Coordinates Coordonnées polaires + Polar To Rectangular Polaires vers rectangulaires + Policy Cmyk Règle CMJN + Policy Gray Règle niveaux de gris + Policy Rgb Règle RVB + Pond Ripples Ondulations d'étang + Poster Edges Contours postérisés + Posterization Postérisation + Preferences can't be saved Les préférences ne peuvent pas être enregistrées + Preferences saved Préférences enregistrées + Preferences were reset: {error} Les préférences ont été réinitialisées : {error} + Preferences… Préférences… + Preserve Conserver + Preset Manager Gestionnaire de préréglages + Press keys Appuyez sur les touches + Preview Image Aperçu de l'image + Previous Précédent + Prime105 Focale fixe 105 mm + Prime35 Focale fixe 35 mm + Printed to {path} Imprimé vers {path} + Processing… Traitement… + Profile Profil + Prophoto-compat ProPhoto (compatible) + Pucker Contraction + Push Left Pixels poussés vers la gauche + Pyramids Pyramides + Quit Quitter + Quit PhotoCraft Quitter PhotoCraft + Quit {app} Quitter {app} + Radial Blur Flou radial + Radius X Rayon X + Radius Y Rayon Y + Ran script {name} Script {name} exécuté + Randomize Length Longueur aléatoire + Randomize Shapes Formes aléatoires + Raw Defaults Valeurs Raw par défaut + Reading Lecture + Ready Prêt + Rec2020 Rec2020 + Recent Files Fichiers récents + Reconstruct All Tout reconstruire + Recovered {name} {name} récupéré + Rectangular Selection Sélection rectangulaire + Rectangular To Polar Rectangulaires vers polaires + Red Cyan Rouge/Cyan + Reduce Color Noise Réduire le bruit de couleur + Reduce Noise Réduction du bruit + Relief Relief + Relink to File Lier de nouveau au fichier + Remove All Tout supprimer + Remove Jpeg Artifact Supprimer l'artefact JPEG + Rename Brush Renommer le pinceau + Rename Channel… Renommer le canal… + Rename Group Renommer le groupe + Rename Tool Preset Renommer le préréglage d'outil + Render Video Rendu vidéo + Rendering… Rendu en cours… + Reorder Layer Réorganiser le calque + Replace Color Remplacer la couleur + Replace Contents Remplacer le contenu + Replace Footage Remplacer le métrage + Report a Bug Signaler un bogue + Report an Issue Signaler un problème + Reset All Tout réinitialiser + Reset Preferences Réinitialiser les préférences + Resolve Missing Fonts Résoudre les polices manquantes + Restart Redémarrer + Restart Required Redémarrage requis + Resume Reprendre + Retry Réessayer + Rgb RVB + Ridges Crêtes + Right Diagonal Diagonale droite + Ripple Ondulation + Ripple Magnitude Amplitude de l'ondulation + Ripple Size Taille de l'ondulation + Rope Corde + Roses Roses + Rough Pastels Pastels rugueux + Row Order Ordre des lignes + Sample Size Taille de l'échantillon + Sandstone Grès + Sat Sat. + Save As Enregistrer sous + Save Changes Enregistrer les modifications + Save File Enregistrer le fichier + Save Image Enregistrer l'image + Save Path Enregistrer le tracé + Save a Copy Enregistrer une copie + Save before closing? Enregistrer avant de fermer ? + Save for Web (Legacy) Enregistrer pour le Web (ancien) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Enregistrez votre travail et redémarrez PhotoCraft pour réessayer l'accélération GPU. Le rendu CPU reste actif pour cette session. + Saved a copy as {path} Copie enregistrée sous {path} + Saved {path}; image assets: {n} in {dir} {path} enregistré ; ressources d'image : {n} dans {dir} + Saving… Enregistrement… + Scale Effects Mettre les effets à l'échelle + Scale Factor Facteur d'échelle + Scale Units Unités d'échelle + Scaling Échelle + Scallops Festons + Script Events Manager Gestionnaire d'événements de script + Scroll menu down Faire défiler le menu vers le bas + Scroll menu up Faire défiler le menu vers le haut + Search commands, tools and panels Rechercher des commandes, outils et panneaux + Search menus Rechercher dans les menus + Seed Valeur initiale + Select Custom Color… Choisir une couleur personnalisée… + Select Inverse Inverser la sélection + Select Layer Sélectionner le calque + Select and Mask Sélectionner et masquer + Selected at launch: {backend} Sélectionné au lancement : {backend} + Sensitivity Sensibilité + Sent to the printer ({spooler}) Envoyé à l'imprimante ({spooler}) + Sepia Sépia + Services Services + Set Colors Définir les couleurs + Set Measurement Scale Définir l'échelle de mesure + Sgray sGray + Shadow Ombres + Shadow Amount Quantité des ombres + Shadow Intensity Intensité des ombres + Shadow Radius Rayon des ombres + Shadow Tone Amplitude tonale des ombres + Shadows/Highlights Ombres/Hautes lumières + Shallow Superficiel + Shape Blur Flou selon une forme + Sharpen Amount Quantité de netteté + Sharpen Detail Détail de la netteté + Sharpen Details Détails de la netteté + Sharpen Masking Masquage de la netteté + Sharpen Radius Rayon de netteté + Shear Cisaillement + Shine Brillance + Short Lines Lignes courtes + Short Strokes Traits courts + Shortcut Raccourci + Shortcuts Raccourcis + Show All Tout afficher + Show Rulers Afficher les règles + Simple Simple + Sine Sinus + Sketch Esquisse + Skip Ignorer + Slice Options Options de tranche + Smart Blur Flou intelligent + Smart Sharpen Netteté intelligente + Smoothness Lissage + Smudge Stick Estompe + Snap to Grid Aimanter à la grille + Snowflakes Flocons de neige + Softness Douceur + Software adapter Adaptateur logiciel + Solarized Dark Solarized sombre + Solid Color Couleur unie + Solid Front Faces Faces avant pleines + Source Layer Calque source + Sparkle Étincelle + Spatter Éclaboussures + Speckle Moucheté + Speed Vitesse + Spherize Sphérisation + Spin Rotation + Spin Blur Flou de rotation + Split Channels Séparer les canaux + Spot Spot + Spray Radius Rayon de pulvérisation + Sprayed Strokes Traits pulvérisés + Sprinkles Saupoudré + Square Size Taille des carrés + Squareness Forme carrée + Stagger Décalage + Stained Glass Vitrail + Star Étoile + Stars Étoiles + Start X Début X + Start Y Début Y + Statistics Statistiques + Step {step} ({id}) failed: {error} Échec de l'étape {step} ({id}) : {error} + Stippled Pointillé + Stops Arrêts + Strength Intensité + Stretch Étirement + Stroke Color Couleur du contour + Stroke Detail Détail du trait + Stroke Direction Direction du trait + Stroke Length Longueur du trait + Stroke Pressure Pression du trait + Stroke Size Taille du trait + Stroke Width Largeur du trait + Strokes Traits + Stylization Stylisation + Subtract Shapes at Overlap Soustraire les formes au chevauchement + Sumi-e Sumi-e + Surface Blur Flou de surface + Switch Foreground and Background Colors Permuter les couleurs de premier plan et d'arrière-plan + Tablet Tablette + Taper Effilement + Target Cible + Target X Cible X + Target Y Cible Y + Teal Orange Sarcelle et orange + Temporary Temporaires + Text Layer Calque de texte + Texture Coverage Couverture de la texture + Texturizer Texturisation + Thaw Mask Masque de libération + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Le canevas est affiché en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Le canevas sur {display} est affiché en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. + The current gradient (pick one in Window › Gradients) Le dégradé actuel (à choisir dans Fenêtre › Dégradés) + The document does not have an embedded colour profile. Working space: {working}. Le document ne contient aucun profil colorimétrique incorporé. Espace de travail : {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Le document contient un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail.\nIncorporé : {embedded}\nEspace de travail : {working} + The file could not be opened. Impossible d'ouvrir le fichier. + The file could not be saved. Impossible d'enregistrer le fichier. + Thickness Épaisseur + This Layer: Ce calque : + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Cet objet intelligent doit être rastérisé pour continuer. Son contenu ne sera plus modifiable. Rastériser l'objet intelligent ? + This adjustment ({key}) is kept as imported and can't be edited yet. Cet ajustement ({key}) est conservé tel qu'il a été importé et ne peut pas encore être modifié. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ce calque de remplissage doit être rastérisé pour continuer. Son contenu de remplissage ne sera plus modifiable. Rastériser le calque ? + This layer must be rasterized before proceeding. Ce calque doit être rastérisé pour continuer. + This session: {reason} Cette session : {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ce calque de forme doit être rastérisé pour continuer. Il ne sera plus modifiable en tant que forme. Rastériser la forme ? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ce calque de texte doit être rastérisé pour continuer. Son texte ne sera plus modifiable. Rastériser le texte ? + Tile Size Taille des carreaux + Tiles Carreaux + Tilt-Shift Inclinaison-décalage + Tint Color Couleur de la nuance + Tiny Lens Petites lentilles + To À + To Center Vers le centre + Toggle the Brush Settings panel Afficher/masquer le panneau Paramètres du pinceau + Tool Options Options d'outil + Trace Contour Tracer les contours + Transformation Transformation + Transition Transition + Transparency Shapes Layer La transparence détermine la forme du calque + Trap Recouvrement + Tree Arbre + Trim Rogner les bords + Turbulent Turbulent + Twirl Tourbillon + Twirl Clockwise Tourbillon horaire + Twirl Counterclockwise Tourbillon antihoraire + Unaltered Inchangé + Undefined Areas Zones non définies + Underlying Layer: Calque sous-jacent : + Underpainting Sous-couche + Underwater Sous l'eau + Ungroup Dissocier + Uniform Uniforme + Unite Shapes at Overlap Réunir les formes au chevauchement + Unlink Délier + Unlock Déverrouiller + Unlocked Déverrouillé + Unsaved Changes Modifications non enregistrées + Unsharp Mask Masque flou + Unsupported file format Format de fichier non pris en charge + Unsupported format Format non pris en charge + Up Haut + Update Mettre à jour + Updates Mises à jour + Upper Supérieur + Use Custom Color Utiliser une couleur personnalisée + Use File › Open to open an image. Utilisez Fichier › Ouvrir pour ouvrir une image. + Use Selection In Source Utiliser la sélection dans la source + Use Selection In Target Utiliser la sélection dans la cible + Vanishing Point Point de fuite + Variable Data Sets Jeux de données variables + Vector Mask Hides Effects Le masque vectoriel masque les effets + Vertical Only Vertical seulement + Vertical Type Tool Outil Texte vertical + Video Frames to Layers Images vidéo dans des calques + Vignette Amount Quantité de vignettage + Vignette Feather Adoucissement du vignettage + Vignette Highlights Hautes lumières du vignettage + Vignette Midpoint Point médian du vignettage + Vignette Roundness Arrondi du vignettage + Vignette Style Style du vignettage + Vine Color Couleur de la liane + Vine With Flowers Liane avec fleurs + Vine With Leaves Liane avec feuilles + Violent Violent + WIA Support Prise en charge de WIA + Warm Chaud + Warming L B A Réchauffement LBA + Warming81 Réchauffement 81 + Warming85 Réchauffement 85 + Warmth Chaleur + Warning Avertissement + Warp Text Déformer le texte + Watercolor Aquarelle + Wavelength Max Longueur d'onde max. + Wavelength Min Longueur d'onde min. + Waves Vagues + Wetness Humidité + White Clip Écrêtage des blancs + White Intensity Intensité du blanc + White Is High Le blanc est le plus haut + Wide Blurry Large et flou + Wide Sharp Large et net + Wind Vent + Window renderer: {renderer} Moteur de rendu de la fenêtre : {renderer} + Working Espace de travail + Working Cmyk CMJN de travail + Working Gray Gris de travail + Working Rgb RVB de travail + Workspaces Espaces de travail + Wrap Enrouler + ZigZag Zigzag + Zigzag Zigzag + auto auto + blue bleu + brushes pinceaux + fallback repli + gradients dégradés + gray gris + green vert + layer mask masque de calque + manual manuel + none aucun + off désactivé + on activé + orange orange + previous reading kept; the last re-read failed: {reason} lecture précédente conservée ; la dernière relecture a échoué : {reason} + red rouge + unknown inconnu + unknown type type inconnu + vector mask masque vectoriel + violet violet + yellow jaune + {index} of {total} {index} sur {total} + {label} is not available: {reason} {label} n'est pas disponible : {reason} + {message} (notes: {n}; {warning}) {message} (remarques : {n} ; {warning}) + {shortcut} assigned. {shortcut} attribué. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} est déjà utilisé par {commands} et lui sera retiré lorsque vous cliquerez sur OK. + {verb} {mask} {layer} {verb} le {mask} ({layer}) + ✓ Color Indicates Masked Areas ✓ La couleur indique les zones masquées + ✓ Color Indicates Selected Areas ✓ La couleur indique les zones sélectionnées +@id edit.clear Effacer +@id edit.stroke Contourner… +@id edit.transform.scale Mise à l'échelle +@id file.close Fermer +@id filter.sharpen.sharpen Netteté +@id layer.layerMask.apply Appliquer le masque de calque +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} nuance exportée vers {file}|{n} nuances exportées vers {file} +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {n} pinceau importé depuis {file}|{n} pinceaux importés depuis {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pinceau importé depuis {group}|{n} pinceaux importés depuis {group} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {n} dégradé importé depuis {file}|{n} dégradés importés depuis {file} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} nuance importée depuis {file}|{n} nuances importées depuis {file} +@plural Played {n} step|Played {n} steps {n} étape lue|{n} étapes lues +@plural Saved for Web: {n} file|Saved for Web: {n} files Enregistré pour le Web : {n} fichier|Enregistré pour le Web : {n} fichiers +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Mise à jour de {n} objet intelligent lié modifié sur le disque|Mise à jour de {n} objets intelligents liés modifiés sur le disque +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} calque enregistré par cette composition a été supprimé. Mettez la composition à jour ou effacez l'avertissement.|{n} calques enregistrés par cette composition ont été supprimés. Mettez la composition à jour ou effacez l'avertissement. +@plural {n} plane|{n} planes {n} plan|{n} plans +@plural {n} preset|{n} presets {n} préréglage|{n} préréglages +@plural {n} sample point removed|{n} sample points removed {n} point d'échantillonnage supprimé|{n} points d'échantillonnage supprimés +@plural {n} slice|{n} slices {n} tranche|{n} tranches +@plural {n} step|{n} steps {n} étape|{n} étapes +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} autre avertissement de préréglage de pinceau)|{warning} (+{n} autres avertissements de préréglages de pinceau) +@plural {what} with {n} warning|{what} with {n} warnings {what} avec {n} avertissement|{what} avec {n} avertissements +cameraRaw Aqua Aqua +cameraRaw Balance Balance +cameraRaw Blue Bleu +cameraRaw Clarity Clarté +cameraRaw Color Couleur +cameraRaw Color Detail Détail des couleurs +cameraRaw Color Grading Étalonnage des couleurs +cameraRaw Color Mixer Mélangeur de couleurs +cameraRaw Contrast Contraste +cameraRaw Curve Courbe +cameraRaw Dehaze Correction de la brume +cameraRaw Detail Détail +cameraRaw Effects Effets +cameraRaw Exposure Exposition +cameraRaw Feather Adoucissement +cameraRaw Global Global +cameraRaw Green Vert +cameraRaw Highlights Hautes lumières +cameraRaw Hue Teinte +cameraRaw Luminance Luminance +cameraRaw Luminance Detail Détail de la luminance +cameraRaw Magenta Magenta +cameraRaw Midpoint Point médian +cameraRaw Midtones Tons moyens +cameraRaw Orange Orange +cameraRaw Purple Violet +cameraRaw Radius Rayon +cameraRaw Red Rouge +cameraRaw Roughness Rugosité +cameraRaw Roundness Arrondi +cameraRaw Saturation Saturation +cameraRaw Shadows Ombres +cameraRaw Size Taille +cameraRaw Temperature Température +cameraRaw Texture Texture +cameraRaw Vibrance Vibrance +cameraRaw Whites Blancs +cameraRaw Yellow Jaune +colorRange Invert Inverser +document Orientation Orientation +layerLabel Magenta Magenta +layerLabel No Color Aucune couleur +layerLabel Red Rouge +layers Fill Remplissage +layers Fill: Remplissage : +navigator Zoom Zoom +shape Appearance Apparence + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Sens du paragraphe automatique + Left-to-right paragraph direction Sens du paragraphe de gauche à droite + Right-to-left paragraph direction Sens du paragraphe de droite à gauche diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv new file mode 100644 index 0000000000..fe630fa065 --- /dev/null +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -0,0 +1,491 @@ +# Hindi (hi) catalog. Columns, separated by a TAB: context source 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 पीला diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 286ce41f0a..4dab4d6887 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1fe7c32b24..f965b61949 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3750f43092..c38dbe36f6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dcc8b0aa10..728f7b5c74 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 右から左への段落方向 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 24e10fa6e5..b336b8a2b9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 오른쪽에서 왼쪽 단락 방향 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 3e7ea830e5..50dc5c2f22 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -187,7 +187,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result { } /// 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 { + let mut rest: Vec = 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 { @@ -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 = 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() { diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 961a707a6e..4e5cf70f35 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6ecf85ef2b..ae1824012a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index bf40b31e38..2557769480 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9352cd240a..565d1ef8cb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -783,6 +783,7 @@ Set shape fill type Тип заливки фигуры Set shape stroke type Тип обводки фигуры Set the text color Цвет текста + Create warped text Создать деформированный текст Only Web Colors Только web-цвета Overlay Color Цвет наложения Overlay Opacity Непрозрачность наложения @@ -3272,9 +3273,511 @@ curves Smooth Сгладить Replace Заменить {name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь. Replace it with this document, or cancel and use File › Save As to keep both. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. - Decrease Brush Size Уменьшить размер кисти - Increase Brush Size Увеличить размер кисти + + +# Dynamic UI labels (fourth pass): filter and adjustment parameters, Camera Raw panels, +# lighting, History/Layers/paths/channels menus, metadata, System Info, status templates and +# dialog text that other catalogs already carry. Written from the meaning of the English labels +# in the ordinary Russian graphics-editor vocabulary; no proprietary resource consulted. + (coming soon) (скоро) + (none) (нет) + 32-bit Preview Options Параметры предпросмотра 32 бит + 3D… 3D… + A: Ш: + AI Integrations Интеграции ИИ + About {app} О программе {app} + Accented Edges Акцентированные края + Action playback failed: {error} Не удалось воспроизвести операцию: {error} + Actual Pixels Реальный размер + Add Mask Добавить маску + Add Noise Добавить шум + Add a mask (from the selection; ⌥ inverts) Добавить маску (из выделенной области; ⌥ инвертирует) + Add space after paragraph Добавить интервал после абзаца + Add space before paragraph Добавить интервал перед абзацем + Add to selection (⇧) Добавить к выделенной области (⇧) + Added swatch {name} Добавлен образец {name} + Adds grain to the soft edges of the brush tip. Добавляет зерно к мягким краям кисти. + Adjust Interval For Loops Настроить интервал циклов + Adjustment Layers Корректирующие слои + Adjustment Properties Свойства коррекции + Adjustment layers have nothing to transform В корректирующих слоях нечего трансформировать + Adobe PDF Presets… Наборы Adobe PDF… + Adobe-rgb-compat Adobe RGB (совместимо) + Adwaita (Dark) Adwaita (тёмная) + Adwaita (Light) Adwaita (светлая) + After После + Airbrush: keeps painting while the pointer rests. Аэрограф: продолжает рисовать, пока указатель неподвижен. + All shortcuts reset to Photoshop defaults. Все сочетания клавиш сброшены к значениям Photoshop по умолчанию. + Altitude Высота + Ambience Атмосфера + Ambient Окружающий свет + Amplitude Max Макс. амплитуда + Amplitude Min Мин. амплитуда + Android 1080p Android 1080p + Angled Strokes Наклонные штрихи + Anisotropic Анизотропный + Any Любой + Apple Watch 45mm Apple Watch 45 мм + Apply Data Set Применить набор данных + Apply Image Внешний канал + Apply Mask Применить маску + Apply to All Применить ко всем + Apply {option}: {state} Применить {option}: {state} + Arbitrary Произвольный + Arc Lower Нижняя дуга + Arc Upper Верхняя дуга + Are you sure you want to quit? Вы действительно хотите выйти? + Are you sure? Вы уверены? + Around Center Вокруг центра + Arrangement Расположение + Arrows Стрелки + Artboard from Group Монтажная область из группы + Artboard from Layers Монтажная область из слоёв + Artboards Монтажные области + Artboards to Files Монтажные области в файлы + Artboards to PDF Монтажные области в PDF + Artistic Художественные + Ask On Mismatch Спрашивать при несовпадении + Ask On Missing Спрашивать при отсутствии + Ask On Paste Спрашивать при вставке + Assign Profile Назначить профиль + At Позиция + Author Title Имя автора + Auto Scale Автомасштаб + Auto-Align Layers Автовыравнивание слоёв + Auto-Blend Layers Автосмешивание слоёв + Autosave failed: {error} Не удалось выполнить автосохранение: {error} + Back Назад + Backend: {backend} ({type}) Сервер: {backend} ({type}) + Background Level Уровень фона + Bamboo Бамбук + Bas Relief Барельеф + Base Tree Type Базовый тип дерева + Based On На основе + Baseline shift Смещение базовой линии + Basic Latin Основная латиница + Batch Пакетная обработка + Before До + Bend Изгиб + Berries Ягоды + Best Наилучшее + Black Clip Отсечение чёрного + Black Intensity Интенсивность чёрного + Blade Curvature Кривизна лепестков диафрагмы + Blast Порыв + Bleach Bypass Пропуск отбеливания + Blend Clipped Layers as Group Смешивать обтравочные слои как группу + Blend Interior Effects as Group Смешивать внутренние эффекты как группу + Blend Text Gamma Гамма смешивания текста + Blending Mode Режим наложения + Bloat Вздутие + Blocks Блоки + Blue Yellow Синий–жёлтый + Blues Синие тона + Border Граница + Border Thickness Толщина границы + Box Blur Размытие по рамке + Bpc Компенсация чёрной точки + Branches Color Цвет ветвей + Branches Height Высота ветвей + Branches Thickness Толщина ветвей + Breeze Dark Breeze (тёмная) + Breeze Light Breeze (светлая) + Brick Кирпич + Bristle Detail Детализация щетины + Browse Обзор + Brush Detail Детализация кисти + Brush Settings… (F5): every option of this brush Параметры кисти… (F5): все параметры этой кисти + Brush Strokes Мазки кисти + Brush Type Тип кисти + Burlap Мешковина + Calculations Вычисления + Camera Raw Camera Raw + Camera Raw Filter Фильтр Camera Raw + Cancel transform Отменить трансформирование + Candle Light Свет свечи + Canvas Size Размер холста + Case Sensitive Учитывать регистр + Ccw Против часовой стрелки + Cell Size Размер ячейки + Center X Центр X + Center Y Центр Y + Chalk Area Область мела + Change Find Найти и заменить + Changes Saved Изменения сохранены + Changes have not been saved. Изменения не сохранены. + Change… Изменить… + Channel1 Канал 1 + Channel2 Канал 2 + Channel3 Канал 3 + Channel4 Канал 4 + Charcoal Area Область угля + Charcoal Thickness Толщина угля + Check for Updates Проверить обновления + Checkerboard Шахматная доска + Choke Поджатие + Choose Folder Выбрать папку + Circle Круг + Cleanliness Чистота + Clear Amount Величина очистки + Clear Selection Отменить выделение + Click sets foreground · right-click sets background Щелчок задаёт цвет переднего плана · щелчок правой кнопкой задаёт цвет фона + Click the first point or press ↵ to close · Esc cancels Щёлкните первую точку или нажмите ↵, чтобы замкнуть · Esc отменяет + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Щёлкните, чтобы добавить точку · перетащите за пределы, нажмите ⌘/Ctrl и щёлкните или нажмите Delete, чтобы удалить + Click to apply, ⌥-click to clear overrides, double-click for Style Options Щёлкните, чтобы применить, ⌥+щелчок, чтобы очистить переопределения, двойной щелчок — параметры стиля + Click to zoom in · ⌥-click to zoom out Щёлкните, чтобы увеличить · ⌥+щелчок, чтобы уменьшить + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Щелчок: угол · Перетаскивание: сглаживание · Щелчок по первой точке: замкнуть · ↩ завершить · Esc отменить + Clipping Mask Обтравочная маска + Clipping Path… Контур отсечения… + Clone Клонировать + Clone source set at {x}, {y} Источник клонирования задан в {x}, {y} + Close Window Закрыть окно + Clumped Сгруппированные + Cmyk CMYK + Coarse Dots Крупные точки + Coated-cmyk Мелованная CMYK + Collapse group (⌥-click: all groups) Свернуть группу (⌥+щелчок: все группы) + Color Decontaminate Удаление цветового ореола + Color Halftone Цветные полутона + Color Indicates Masked Areas Цветом обозначены маскированные области + Color Indicates Selected Areas Цветом обозначены выделенные области + Color Lookup Tables Таблицы перекодировки цвета + Color Picker Палитра цветов + Color Picker (Background Color) Палитра цветов (цвет фона) + Color Picker (Custom Canvas Color) Палитра цветов (свой цвет холста) + Color Picker (Foreground Color) Палитра цветов (цвет переднего плана) + Color Sampler Tool Инструмент «Цветовой эталон» + Color Settings Настройки цвета + Color Swatches Образцы цветов + Color Table Цветовая таблица + Color management: {error} Управление цветом: {error} + Colored Pencil Цветной карандаш + Comic Book Комикс + Commit Perspective Warp (↩) Применить перспективную деформацию (↩) + Commit Puppet Warp (↩) Применить марионеточную деформацию (↩) + Commit any current edits (⌘↩) Применить текущие изменения (⌘↩) + Commit current crop operation (↵) Применить текущую операцию кадрирования (↵) + Commit transform Применить трансформирование + Commit transform (↩) Применить трансформирование (↩) + Commit warp (↩) Применить деформацию (↩) + Compare Сравнить + Conditional Mode Change Условная смена режима + Confirm Подтвердить + Conform Подогнать + Constraints Ограничения + Content-Aware Fill needs a selection on a pixel layer Заливка с учётом содержимого требует выделенной области на пиксельном слое + Continue Продолжить + Contrasty Контрастный + Convert Преобразовать + Convert to Alpha Channel Преобразовать в альфа-канал + Convert to Linked Преобразовать в связанный + Convert to Profile Преобразовать в профиль + Cool Холодный + Cooling L B B Охлаждающий (LBB) + Cooling80 Охлаждающий (80) + Cooling82 Охлаждающий (82) + Copy Complete Stroke Копировать полный контур + Copy Fill Копировать заливку + Copyright Url URL авторских прав + Correct C A Коррекция хроматической аберрации + Correct Distortion Коррекция дисторсии + Correct Vignette Коррекция виньетирования + Could not rename the action: {error} Не удалось переименовать операцию: {error} + Could not transform: the layer is empty Не удалось трансформировать: слой пуст + Could not use Perspective Warp: the layer is empty Не удалось применить перспективную деформацию: слой пуст + Could not use Puppet Warp: no opaque pixels Не удалось применить марионеточную деформацию: нет непрозрачных пикселей + Could not use Puppet Warp: the layer is empty Не удалось применить марионеточную деформацию: слой пуст + Couldn't open {name}: {error} Не удалось открыть {name}: {error} + Couldn't save preferences: {error}. Retrying… Не удалось сохранить настройки: {error}. Повтор… + Crack Brightness Яркость трещин + Crack Depth Глубина трещин + Crack Spacing Интервал трещин + Craquelure Кракелюр + Create By Создать с помощью + Create Vector Mask Создать векторную маску + Create Warped Text Создать деформированный текст + Create a new layer (⇧⌘N) Создать новый слой (⇧⌘N) + Create new brush from the current settings Создать новую кисть из текущих параметров + Create new brush preset from the current settings Создать новый набор кистей из текущих параметров + Create… Создать… + Cross Process Кросс-процесс + Crosshatch Перекрёстная штриховка + Crosswise Перекрёстно + Crystallize Кристаллизация + Currency Валюта + Curve Darks Кривая: тёмные тона + Curve Highlights Кривая: светлые тона + Curve Lights Кривая: светлые тона + Curve Shadows Кривая: тени + Curve Splits Кривая: разрывы + Custom Branch Color Свой цвет ветвей + Custom Pixel Aspect Ratio Своё пиксельное соотношение + Custom pattern Свой узор + Cutout Аппликация + Cw По часовой стрелке + Cyans Голубые тона + Cyrillic Кириллица + Daisies Маргаритки + Dark Gray Тёмно-серый + Dark Intensity Интенсивность тёмного + Dark Rough Тёмный шероховатый + Dark Strokes Тёмные штрихи + Darken Only Только затемнение + Darkness Затемнение + Data Данные + Data Set: {name} ({index}/{total}) Набор данных: {name} ({index}/{total}) + Data Sets as Files Наборы данных в файлы + Date Дата + Day For Night День как ночь + De-Interlace Устранение чересстрочности Decrease Brush Hardness Уменьшить жёсткость кисти + Decrease Brush Size Уменьшить размер кисти + Decrease Type Size Уменьшить размер текста + Decrease Type Size by 10 Уменьшить размер текста на 10 + Deep Глубокий + Deep Blue Глубокий синий + Deep Emerald Глубокий изумрудный + Deep Red Глубокий красный + Deep Yellow Глубокий жёлтый + Default Foreground and Background Colors Цвета переднего плана и фона по умолчанию + Default Leaves Листья по умолчанию + Default Photoshop Size Размер Photoshop по умолчанию + Defaults По умолчанию + Define Brush Preset Определить кисть + Define Custom Shape Определить свою фигуру + Define Pattern Определить паттерн + Definition Чёткость + Defringe Убрать кайму + Degrees Градусы + Delete Channel Удалить канал + Delete Mask Удалить маску + Delete Workspace Удалить рабочую среду + Delete brush Удалить кисть + Delete path Удалить контур + Delete vector mask Удалить векторную маску + Delete… Удалить… + Depth Map Карта глубины + Depth Mode Режим глубины + Diameter Диаметр + Diffuse Диффузия + Diffuse Glow Рассеянное свечение + Direction Направление + Direction Balance Баланс направлений + Disable Выключить + Disable Mask Отключить маску + Disable Symmetry Path Отключить контур симметрии + Disabled Отключено + Discard Changes Отменить изменения + Displace Смещение + Display Дисплей + Display profile not used Профиль дисплея не используется + Display profiles: {reason} Профили дисплеев: {reason} + Display-p3 Display P3 + Distance Расстояние + Distortion Дисторсия + Distribution Распределение + Do you want to save you made to “{name}” before quitting? Сохранить изменения в «{name}» перед выходом? + Document Sizes Размеры документа + Documentation Документация + Documents Документы + Dont Enlarge Не увеличивать + Dot Точка + Double Line Двойная линия + Down Вниз + Draft Черновик + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Перетащите рамку кадрирования · перетаскивание внутри перемещает · края изменяют размер (соотношение {ratio}, центр {centre}) · пробел перемещает при рисовании · {commit} применяет · Esc отменяет + Drag: draw freehand · Release on the start point: close Перетаскивание: рисование от руки · Отпустите на начальной точке: замкнуть + Driver: {driver} Драйвер: {driver} + Dry Brush Сухая кисть + Duplicate Channel Дублировать канал + Duplicate Layers Дублировать слои + Duplication Дублирование + Dust & Scratches Пыль и царапины + Edge Край + Edge Brightness Яркость краёв + Edge Extension Расширение краёв + Edge Fidelity Точность краёв + Edge Intensity Интенсивность краёв + Edge Only Только края + Edge Simplicity Простота краёв + Edge Thickness Толщина краёв + Edge Width Ширина краёв + Edit Toolbar Настроить панель инструментов + Edit… Изменить… + Elevation Возвышение + Eliminate Устранить + Emboss Тиснение + Enable Включить + Enable Mask Включить маску + End X Конец X + End Y Конец Y + Enlarged Увеличенный + Erase with Brush or Pencil (hold) Стирание кистью или карандашом (удерживайте) + Error Ошибка + Even Fields Чётные поля + Exit Quick Mask Выйти из быстрой маски + Expand group (⌥-click: all groups) Развернуть группу (⌥+щелчок: все группы) + Export As Экспортировать как + Export As: {name} Экспортировать как: {name} + Export Contents Экспортировать содержимое + Export Image Экспортировать изображение + Export Options Параметры экспорта + Export Preferences Экспорт настроек + Export Settings Экспортировать настройки + Export to Экспортировать в + Export/Import Presets Экспорт/импорт наборов + Exported contents to {path} Содержимое экспортировано в {path} + Exported presets to {path} Наборы экспортированы в {path} + Exported {path} Экспортировано: {path} + Exported {path} ({size}) Экспортировано: {path} ({size}) + Exporting… Экспорт… + ExtraBold Сверхжирный + ExtraLight Сверхтонкий + Extrude Экструзия + Fade Ослабить + Faded Film Выцветшая плёнка + Failed Не удалось + Failed to open file Не удалось открыть файл + Failed to save file Не удалось сохранить файл + Fallback: {reason} Резерв: {reason} + Feather Radius Радиус растушёвки + Fiber Length Длина волокон + Fibers Волокна + Field Blur Размытие по полю + File Format Формат файла + File Info Сведения о файле + File Name Имя файла + File Size Размер файла + File not found Файл не найден + Filename Имя файла + Fill Color Цвет заливки + Fill Opacity Непрозрачность заливки + Fill Path… Залить контур… + Fill path with foreground color Залить контур цветом переднего плана + Fill with Background Color Залить цветом фона + Fill with Background Color, Preserve Transparency Залить цветом фона, сохранить прозрачность + Fill with Foreground Color Залить цветом переднего плана + Fill with Foreground Color, Preserve Transparency Залить цветом переднего плана, сохранить прозрачность + Film Grain Зернистость плёнки + Filter Gallery Галерея фильтров + Find Найти + Find and Replace Text Найти и заменить текст + Fine Тонкий + Fine Dots Мелкие точки + Finish Готово + Fit Image Подогнать изображение + Fit to Window По размеру окна + Flame Пламя + Flame Bottom Alignment Выравнивание низа пламени + Flame Lines Линии пламени + Flame Shape Форма пламени + Flame Style Стиль пламени + Flame Type Тип пламени + Flat Плоский + Flat Shading Плоское затенение + Flower Color Цвет цветков + Focal Distance Дистанция фокуса + Focus Фокус + Focus Area Область фокусировки + Foreground Level Уровень переднего плана + Forward Вперёд + Forward Warp Прямая деформация + Frame Кадр + Frame {current} / {total} Кадр {current} / {total} + Free Transform Path Свободное трансформирование контура + Freeze Lasso Заморозить лассо + Freeze Mask Заморозить маску + Fresco Фреска + From От + From Left Слева + From Right Справа + Frosted Матовое + Full Screen Во весь экран + Fullscreen Полноэкранный + Fuzziness Разброс + GPU backend preference: {backend} Предпочитаемый сервер GPU: {backend} + GPU device was lost; using the CPU renderer. Устройство GPU потеряно; используется отрисовка на CPU. + GPU error; using the CPU renderer. Ошибка GPU; используется отрисовка на CPU. + GPU retry scheduled Запланирована повторная попытка GPU + Gamma Гамма + Gaussian Blur Размытие по Гауссу + General Blending Общее наложение + General Brushes Общие кисти + Generators Генераторы + Generic Общий + Glass Стекло + Global Light Глобальный свет + Gloss Глянец + Glow Amount Величина свечения + Glow Brightness Яркость свечения + Glow Size Размер свечения + Glowing Edges Светящиеся контуры + Good Хорошее + Grade Balance Цветокоррекция: баланс + Grade Blending Цветокоррекция: смешивание + Grade Global Цветокоррекция: общая + Grade Highlights Цветокоррекция: светлые тона + Grade Midtones Цветокоррекция: средние тона + Grade Shadows Цветокоррекция: тени + Gradient Options Параметры градиента + Grain Amount Величина зерна + Grain Roughness Шероховатость зерна + Grain Size Размер зерна + Grain Type Тип зерна + Graininess Зернистость + Grainy Dots Зернистые точки + Graphics adapter Графический адаптер + Graphics adapter: {adapter} Графический адаптер: {adapter} + Gray-gamma-2.2 Серый (гамма 2,2) + Greek Греческий + Greens Зелёные тона + Grid (⌘') Сетка (⌘') + Grid view Вид сеткой + Group from Layers Группа из слоёв + Groups Группы + Grout Width Ширина швов + Guide Layout… Схема направляющих… + HDR Toning HDR-тонирование + Hand Tool (hold) Инструмент «Рука» (удерживайте) + Hearts Сердца + Heptagon Семиугольник + Hexagon Шестиугольник + Hidden Скрыто + Hide Grid Скрыть сетку + Hide Others Скрыть остальные + Hide the layer's effects (⌥-click: all layers) Скрыть эффекты слоя (⌥+щелчок: все слои) + Hide {app} Скрыть {app} + High Pass Цветовой контраст + Highlight Света + Highlight Amount Величина светов + Highlight Area Область светов + Highlight Radius Радиус светов + Highlight Strength Сила светов + Highlight Tone Тон светов + Highlights from: Света от: + Horizontal Distortion Горизонтальная дисторсия + Horizontal Only Только по горизонтали + Horizontal scale Масштаб по горизонтали + Horizontal type Горизонтальный текст + Hotspot Горячая точка + Hsb HSB + Hsl HSL + Hsl Hue Оттенок HSL + Hsl Lum Яркость HSL + Hsl Sat Насыщенность HSL + Image Balance Баланс изображения + Image Processor Обработчик изображений + Image compositor: {processor} Компоновщик изображения: {processor} + Import Brushes Импортировать кисти + Import Brushes… (.abr) Импортировать кисти… (.abr) + Import Image Импортировать изображение + Import Options Параметры импорта + Import Photoshop brushes (.abr) Импортировать кисти Photoshop (.abr) + Import Settings Импортировать настройки + Importing {what} from {file}… Импорт {what} из {file}… + Importing… Импорт… + In Black Входной чёрный + In White Входной белый Increase Brush Hardness Увеличить жёсткость кисти Demosaic Демозаик How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Как восстанавливается мозаика цветного фильтра камеры. Применяется при открытии файла; превью показывает метод, с которым файл был открыт. @@ -3317,3 +3820,732 @@ curves Smooth Сгладить Resize Windows to Fit Подгонять размер окон EXR EXR + Increase Brush Size Увеличить размер кисти + Increase Type Size Увеличить размер текста + Increase Type Size by 10 Увеличить размер текста на 10 + Indent first line Отступ первой строки + Indent left margin Отступ слева + Indent right margin Отступ справа + Indexed Индексированный + Infinite Бесконечный + Information Информация + Initial Direction Начальное направление + Ink Outlines Обводка контура + Inner Bevel Внутренняя фаска + Input Вход + Input Levels Входные уровни + Input Mode Режим ввода + Install Plug-in Установить плагин + Intensity Интенсивность + Intent Метод преобразования + Interpret Footage Интерпретировать материал + Intersect with selection (⇧⌥) Пересечение с выделенной областью (⇧⌥) + Interval Интервал + Invalid panel layout; restored the default dock Недопустимый макет панелей; восстановлена пристыковка по умолчанию + Invert Mask Инвертировать маску + Invert Selection Инвертировать выделенную область + Iris Blur Диафрагменное размытие + Ivy Плющ + Jag Зубчатость + Join the ArtCraft Discord Discord сообщества ArtCraft + Join the ArtCraft Discord ({url}) Присоединиться к Discord ArtCraft ({url}) + Keeps the current texture when switching presets. Сохраняет текущую текстуру при смене набора. + Kernel Ядро + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Кернинг: метрический, оптический или значение в 1/1000 em (Alt+←/→ у курсора) + Keyboard Клавиатура + Lab Lab + Landscape, 10 x 8 Альбомная, 10 x 8 + Landscape, 6 x 4 Альбомная, 6 x 4 + Landscape, 7 x 5 Альбомная, 7 x 5 + Latin 1 Латиница 1 + Latin Extended-A Латиница расширенная A + Latin Extended-B Латиница расширенная B + Layer Comps to Files Варианты слоя в файлы + Layer Content Options Параметры содержимого слоя + Layer Count Число слоёв + Layer From Background Слой из фона + Layer Knocks Out Drop Shadow Слой выбивает тень + Layer Mask Hides Effects Маска слоя скрывает эффекты + Layer Properties Свойства слоя + Layer Styles Стили слоя + Layer from Background Слой из фона + Layers to Files Слои в файлы + Layout Макет + Leading (set to the font size × auto-leading when Auto) Интерлиньяж (при значении «Авто» равен размеру шрифта × автоматический интерлиньяж) + Leaf Color Цвет листьев + Learn More Подробнее + Leaves Amount Количество листьев + Leaves Color Цвет листьев + Leaves Size Размер листьев + Left Diagonal Левая диагональ + Legacy Устаревший + Legal Legal + Lens Blur Размытие при малой глубине + Lens Correction Коррекция дисторсии + Lens Flare Блик от объектива + Less Меньше + Letter Letter + Level Based На основе уровней + Library Библиотека + Light Dark Balance Баланс светлого и тёмного + Light Direction Направление света + Light Intensity Интенсивность света + Light Rough Светлый шероховатый + Light Type Тип света + Light X Свет X + Light Y Свет Y + Light Z Свет Z + Lighten Grout Осветлить швы + Lighten Only Только осветление + Lighting Освещение + Lighting Effects Эффекты освещения + Linear Dodge Линейный осветлитель + Linear Height Линейная высота + Linear-srgb Линейный sRGB + Link Связать + Liquify Пластичность + List Список + List view Вид списком + Live Preview Живой просмотр + Load 3D LUT Загрузить 3D LUT + Load Files into Stack Загрузить файлы в стопку + Load path as a selection Загрузить контур как выделенную область + Loading… Загрузка… + Lock All Заблокировать всё + Lock Image Pixels Заблокировать пиксели изображения + Lock Position Закрепить положение + Lock Transparent Pixels Заблокировать прозрачные пиксели + Locked Заблокировано + Long Lines Длинные линии + Long Strokes Длинные штрихи + Lorem Ipsum Lorem Ipsum + Lower Нижний + Lum Ярк. + Lut LUT + Magentas Пурпурные тона + Make Selection from Path Создать выделенную область из контура + Make Selection… Создать выделенную область… + Make Symmetry Path Создать контур симметрии + Make Work Path… Создать рабочий контур… + Make work path from selection Создать рабочий контур из выделенной области + Manual Вручную + Map Document Документ карты + Map Layer Слой карты + Map Path Путь к карте + Mask Channel Канал-маска + Mask Incomplete Blocks Маскировать неполные блоки + Mask None Без маски + Masks Маски + Match Color Подбор цвета + Math Symbols Математические символы + Max Offset Макс. смещение + Max Radius Макс. радиус + Measurement Scale Масштаб измерений + Measurement Scale Marker Маркер масштаба измерений + Medium Dots Средние точки + Medium Gray Средне-серый + Medium Lines Средние линии + Medium Strokes Средние штрихи + Merge Channels… Объединить каналы… + Merge Spot Channel Объединить цветовой канал + Merge to HDR Pro Объединить в HDR Pro + Metallic Металлический + Metrics Метрики + Mezzotint Меццотинто + Middle Середина + Midtone Полутон + Monitor Profile Профиль монитора + Monitor profile in use on {display}: {profile} Используемый профиль монитора на {display}: {profile} + Monitor profile in use: {profile} Используемый профиль монитора: {profile} + Monitor profile on {display} (main window): {profile} Профиль монитора для {display} (главное окно): {profile} + Monitor profile on {display}: {profile} Профиль монитора для {display}: {profile} + Monitor profile: {profile} Профиль монитора: {profile} + Mono Contrast Монохромный контраст + Monochromatic Монохромный + More Больше + Mosaic Мозаика + Mosaic Tiles Мозаичные плитки + Motion Blur Размытие в движении + Mouse Мышь + Move Layer Переместить слой + Movie Prime Киносъёмочный фикс + Multiple Flames Along Path Несколько языков пламени вдоль контура + Multiple Flames One Direction Несколько языков пламени в одном направлении + Multiple Flames Path Directions Несколько языков пламени по направлениям контура + Multiple Flames Various Length Несколько языков пламени разной длины + My Brushes Мои кисти + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Перетаскивание файлов ещё не поддерживается в Wayland. Используйте «Файл › Открыть» или запустите PhotoCraft под XWayland с `WAYLAND_DISPLAY= photocraft`. + Nearest Ближайший + Neon Glow Неоновое свечение + Neutralize Нейтрализовать + Neutrals Нейтральные тона + New Brush Preset Новый набор параметров кисти + New Channel… Новый канал… + New Guide Новая направляющая + New Guide Layout Схема направляющих + New Spot Channel… Новый цветовой канал… + New Video Layer from File Новый видеослой из файла + New Window Новое окно + New Workspace Новая рабочая среда + New document Новый документ + New document… Новый документ… + New document… ⌘N Новый документ… ⌘N + Next Далее + No Document Нет документа + No Image Нет изображения + No Layers Нет слоёв + No Results Нет результатов + No Results Found Ничего не найдено + No Selection Нет выделенной области + No custom shape "{name}": pick one in the options bar or Window › Shapes Нет произвольной фигуры «{name}»: выберите её на панели параметров или в меню «Окно › Фигуры» + No layers Нет слоёв + No recent files Нет недавних файлов + No selection Нет выделенной области + Noise Color Цветовой шум + Noise Color Detail Цветовая детализация шума + Noise Luminance Яркостный шум + Noise Luminance Detail Яркостная детализация шума + Nothing to Redo Нечего повторить + Nothing to Undo Нечего отменить + Nothing to paste: the clipboard holds no image Нечего вставлять: в буфере обмена нет изображения + Number Of Levels Число уровней + Numbers Числа + Ocean Ripple Океанские волны + Octagon Восьмиугольник + Odd Fields Нечётные поля + Oil Paint Масляная живопись + On Вкл. + One Flame Along Path Один язык пламени вдоль контура + Open As Открыть как + Open File Открыть файл + Open Folder Открыть папку + Open Image Открыть изображение + Open Recent is unavailable on the web «Открыть недавние» недоступно в веб-версии + Opened {url} Открыто: {url} + Opening Открытие + Open… ⌘O Открыть… ⌘O + Operation canceled Операция отменена + Operation cancelled Операция отменена + Operation completed Операция завершена + Operation failed Операция не выполнена + Optical Оптический + Original Ratio Исходное соотношение + Out Black Выходной чёрный + Out From Center От центра наружу + Out Of Gamut Вне охвата + Out White Выходной белый + Out of memory Недостаточно памяти + Outer Bevel Внешняя фаска + Output Levels Выходные уровни + Oval Овал + Overlay Edge Наложение края + Overlay: Rule of Thirds Наложение: правило третей + PSD PSD + Package Упаковать + Page Setup… Параметры страницы… + Paint Daubs Мазки краской + Paints from the document's opening state Рисует по состоянию документа на момент открытия + Palette Knife Шпатель + Panel Панель + Paper Бумага + Paper Brightness Яркость бумаги + Parallel Параллельно + Paste Complete Stroke Вставить полный контур + Paste Fill Вставить заливку + Patchwork Лоскутное шитьё + Path Blur Размытие по контуру + Paths to Illustrator Контуры в Illustrator + Pattern Fill… Заливка узором… + Pattern Type Тип узора + Pencil Width Ширина карандаша + Pentagon Пятиугольник + Percentage Процент + Permission denied Доступ запрещён + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft продолжает попытки; пока сохранение не удастся, изменения настроек будут потеряны при закрытии. + Photomerge Фотослияние + Pick Color From Image Выбрать цвет на изображении + Picture Frame Рамка + Pillow Emboss Подушковидное тиснение + Pinch Сжатие + Pins Булавки + Place Embedded Поместить встроенный + Place Linked Поместить связанный + Place Scale Marker Шкала масштаба + Plane Плоскость + Planes Плоскости + Plastic Wrap Пластиковая упаковка + Platform: {os} {arch} Платформа: {os} {arch} + Please wait… Подождите… + Point Точка + Point Curve Точечная кривая + Point Curve Blue Точечная кривая: синяя + Point Curve Green Точечная кривая: зелёная + Point Curve Red Точечная кривая: красная + Pointed Заострённый + Pointillize Пуантилизм + Polar Coordinates Полярные координаты + Polar To Rectangular Полярные в прямоугольные + Policy Cmyk Правила для CMYK + Policy Gray Правила для градаций серого + Policy Rgb Правила для RGB + Pond Ripples Волны на пруду + Portrait, 4 x 6 Книжная, 4 x 6 + Portrait, 5 x 7 Книжная, 5 x 7 + Postcard Открытка + Poster Плакат + Poster Edges Плакатные края + Posterization Постеризация + Preferences can't be saved Не удаётся сохранить настройки + Preferences saved Настройки сохранены + Preferences were reset: {error} Настройки сброшены: {error} + Preferences… Настройки… + Preserve Сохранить + Preset Manager Управление наборами + Press keys Нажмите клавиши + Preview Image Просмотр изображения + Previous Назад + Prime105 Prime 105 + Prime35 Prime 35 + Printed to {path} Напечатано в {path} + Pro (Dark) Pro (тёмная) + Pro (Medium Gray) Pro (средне-серая) + Processing… Обработка… + Profile Профиль + Proof Setup… Настройка пробного отпечатка… + Prophoto-compat ProPhoto RGB (совместимо) + Pucker Сморщивание + Push Left Сдвиг влево + Pyramids Пирамиды + Quick Actions Быстрые действия + Quit Выход + Quit PhotoCraft Выйти из PhotoCraft + Quit {app} Выйти из {app} + R G B R G B + R g b R g b + Radial Радиальный + Radial Blur Радиальное размытие + Radius X Радиус X + Radius Y Радиус Y + Ran script {name} Выполнен сценарий {name} + Randomize Length Случайная длина + Randomize Shapes Случайные фигуры + Rasterize Fill Content Растрировать содержимое заливки + Rasterize Smart Object Растрировать смарт-объект + Ratio Соотношение + Raw Defaults Значения RAW по умолчанию + Reading Чтение + Ready Готово + Rec2020 Rec. 2020 + Recent Files Недавние файлы + Reconstruct All Восстановить всё + Recovered {name} Восстановлено: {name} + Rectangular Selection Прямоугольная выделенная область + Rectangular To Polar Прямоугольные в полярные + Red Cyan Красный–голубой + Reds Красные тона + Reduce Color Noise Уменьшить цветовой шум + Reduce Noise Уменьшить шум + Reflected Отражение + Relief Рельеф + Relink to File Повторно связать с файлом + Remove All Удалить всё + Remove Jpeg Artifact Удалить артефакты JPEG + Rename Brush Переименовать кисть + Rename Channel… Переименовать канал… + Rename Group Переименовать группу + Rename Tool Preset Переименовать набор + Render Video Экспорт видео + Rendering… Отрисовка… + Reorder Layer Изменить порядок слоя + Replace Color Замена цвета + Replace Contents Заменить содержимое + Replace Footage Заменить материал + Report a Bug Сообщить об ошибке + Report an Issue Сообщить о проблеме + Reset Alignment Сбросить выравнивание + Reset All Сбросить всё + Reset Preferences Сбросить настройки + Resolve Missing Fonts Устранить отсутствующие шрифты + Restart Перезапустить + Restart Required Требуется перезапуск + Resume Продолжить + Retry Повторить + Rgb RGB + Ridges Гребни + Right Diagonal Правая диагональ + Ripple Рябь + Ripple Magnitude Величина ряби + Ripple Size Размер ряби + Rope Верёвка + Roses Розы + Rough Pastels Пастель + Row Order Порядок строк + Rulers (⌘R) Линейки (⌘R) + Sample Size Размер образца + Sampled Colors Отобранные цвета + Sandstone Песчаник + Sat Насыщ. + Save As Сохранить как + Save Changes Сохранить изменения + Save File Сохранить файл + Save Image Сохранить изображение + Save Path Сохранить контур + Save Pixel Aspect Ratio Сохранить соотношение сторон пикселя + Save a Copy Сохранить копию + Save before closing? Сохранить перед закрытием? + Save for Web (Legacy) Сохранить для Web (устаревшее) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Сохраните работу и перезапустите PhotoCraft, чтобы повторить попытку ускорения GPU. Отрисовка на CPU остаётся активной в этом сеансе. + Saved a copy as {path} Копия сохранена как {path} + Saved {path}; image assets: {n} in {dir} Сохранено: {path}; графические ресурсы: {n} в {dir} + Saving… Сохранение… + Scale Effects Масштабировать эффекты + Scaling Масштабирование + Scallops Фестоны + Script Events Manager Диспетчер событий сценариев + Scroll menu down Прокрутить меню вниз + Scroll menu up Прокрутить меню вверх + Search commands Поиск команд + Search commands (⌘K) Поиск команд (⌘K) + Search commands, tools and panels Поиск команд, инструментов и панелей + Search menus Поиск по меню + Seed Зерно + Select Custom Color… Выбрать свой цвет… + Select Data Points Выбрать точки данных + Select Layer Выбрать слой + Select and Mask Выделение и маска + Selected at launch: {backend} Выбрано при запуске: {backend} + SemiBold Полужирный + Sensitivity Чувствительность + Sent to the printer ({spooler}) Отправлено на принтер ({spooler}) + Sepia Сепия + Services Службы + Set Colors Задать цвета + Set Measurement Scale Задать масштаб измерений + Sgray sGray + Shadow Тень + Shadow Amount Величина теней + Shadow Intensity Интенсивность теней + Shadow Radius Радиус теней + Shadow Tone Тон теней + Shadows up to: Тени до: + Shadows/Highlights Тени/Света + Shallow Поверхностный + Shape Blur Размытие по фигуре + Sharpen Amount Величина резкости + Sharpen Detail Детализация резкости + Sharpen Details Резкость деталей + Sharpen Masking Маскирование резкости + Sharpen Radius Радиус резкости + Shear Искривление + Shine Блеск + Short Lines Короткие линии + Short Strokes Короткие штрихи + Shortcut Сочетание клавиш + Shortcuts Сочетания клавиш + Show All Показать все + Show Extras Options Параметры вспомогательных элементов + Show Guides Показать направляющие + Show Guides (⌘;) Показать направляющие (⌘;) + Show Rulers Показать линейки + Show the layer's effects (⌥-click: all layers) Показать эффекты слоя (⌥+щелчок: все слои) + Simple Простой + Simulate Paper Имитировать бумагу + Sine Синусоида + Sketch Эскиз + Skip Пропустить + Slice Options Параметры фрагмента + Smart Blur Умное размытие + Smart Sharpen Умная резкость + Smoothness Сглаженность + Smudge Stick Растушёвка + Snap to Grid Привязать к сетке + Snowflakes Снежинки + Soften Смягчить + Softness Мягкость + Software adapter Программный адаптер + Solarized Dark Solarized (тёмная) + Solid Color Однотонный цвет + Solid Front Faces Сплошные передние грани + Source Channel Канал-источник + Source Invert Инвертировать источник + Source Layer Исходный слой + Source1 Channel Канал источника 1 + Source1 Invert Инвертировать источник 1 + Source2 Channel Канал источника 2 + Source2 Invert Инвертировать источник 2 + Source: set Источник: задан + Space Пробел + Sparkle Искры + Spatter Разбрызгивание + Speckle Крапинки + Speed Скорость + Spherize Сферизация + Spin Вращение + Spin Blur Размытие вращением + Split Channels Разделить каналы + Spot Точечный + Spray Radius Радиус распыления + Sprayed Strokes Разбрызганные штрихи + Spread Разброс + Sprinkles Вкрапления + Square Size Размер квадрата + Square, 12 x 12 Квадратная, 12 x 12 + Square, 5 x 5 Квадратная, 5 x 5 + Squareness Квадратность + Stagger Смещение + Stained Glass Витраж + Star Звезда + Stars Звёзды + Start X Начало X + Start Y Начало Y + Statistics Статистика + Step {step} ({id}) failed: {error} Шаг {step} ({id}) не выполнен: {error} + Stippled Крапчатый + Stops Контрольные точки + Strength Сила + Stretch Растянуть + Stroke Color Цвет обводки + Stroke Detail Детализация обводки + Stroke Direction Направление обводки + Stroke Length Длина обводки + Stroke Pressure Нажим обводки + Stroke Size Размер обводки + Stroke Width Ширина обводки + Stroke path with brush Обвести контур кистью + Strokes Штрихи + Studio (Dark) Studio (тёмная) + Studio (Light) Studio (светлая) + Stylization Стилизация + Subtract Shapes at Overlap Вычесть фигуры при перекрытии + Subtract from selection (⌥) Вычесть из выделенной области (⌥) + Sumi-e Суми-э + Surface Blur Размытие по поверхности + Switch Foreground and Background Colors Поменять цвета переднего плана и фона + System Info Информация о системе + Tablet Планшет + Tabloid Tabloid + Taper Сужение + Target Цель + Target Channel 3 Целевой канал 3 + Target Channel 4 Целевой канал 4 + Target Channel 5 Целевой канал 5 + Target Channel 6 Целевой канал 6 + Target Channel 7 Целевой канал 7 + Target Channel 8 Целевой канал 8 + Target Channel 9 Целевой канал 9 + Target Composite Channel Целевой составной канал + Target X Цель X + Target Y Цель Y + Teal Orange Бирюзовый–оранжевый + Technology Previews… Пробные технологии… + Temporary Временный + Text Layer Текстовый слой + Texture Coverage Покрытие текстуры + Texturizer Текстуризатор + Thaw Mask Разморозить маску + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Холст отображается как sRGB. Выберите профиль монитора в разделе «Редактирование › Настройки цвета». + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Холст на {display} отображается как sRGB. Выберите профиль монитора в разделе «Редактирование › Настройки цвета». + The current gradient (pick one in Window › Gradients) Текущий градиент (выберите в меню «Окно › Градиенты») + The document does not have an embedded colour profile. Working space: {working}. Документ не содержит встроенного цветового профиля. Рабочее пространство: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Документ содержит встроенный цветовой профиль, не совпадающий с рабочим пространством.\nВстроенный: {embedded}\nРабочее пространство: {working} + The file could not be opened. Не удалось открыть файл. + The file could not be saved. Не удалось сохранить файл. + Thickness Толщина + This Layer: Этот слой: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Перед продолжением необходимо растрировать этот смарт-объект. Его содержимое перестанет быть редактируемым. Растрировать смарт-объект? + This adjustment ({key}) is kept as imported and can't be edited yet. Эта коррекция ({key}) сохраняется в импортированном виде и пока не может быть изменена. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Перед продолжением необходимо растрировать этот слой заливки. Содержимое заливки перестанет быть редактируемым. Растрировать слой? + This layer must be rasterized before proceeding. Перед продолжением необходимо растрировать этот слой. + This session: {reason} Этот сеанс: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Перед продолжением необходимо растрировать этот слой фигуры. Он перестанет быть редактируемым как фигура. Растрировать фигуру? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Перед продолжением необходимо растрировать этот текстовый слой. Его текст перестанет быть редактируемым. Растрировать текст? + Tile Size Размер плитки + Tiles Плитки + Tilt-Shift Наклон-сдвиг + Tint Color Цвет тонирования + Tiny Lens Маленькая линза + Title Заголовок + To До + To Center К центру + Toggle Layer Mask Overlay Переключить наложение маски слоя + Toggle the Brush Settings panel Переключить панель «Параметры кисти» + Toggle the Brush Settings panel (F5) Переключить панель «Параметры кисти» (F5) + Tool Options Параметры инструмента + Trace Contour Обводка контуров + Tracking (1/1000 em) Трекинг (1/1000 em) + Transformation Трансформирование + Transition Переход + Transparency Shapes Layer Прозрачность задаёт форму слоя + Trap Приводка + Tree Дерево + Trim Обрезать + Turbulent Турбулентность + Twirl Скручивание + Twirl Clockwise Скручивание по часовой стрелке + Twirl Counterclockwise Скручивание против часовой стрелки + Type Options Параметры текста + Unaltered Без изменений + Undefined Areas Неопределённые области + Underlying Layer: Нижележащий слой: + Underpainting Подмалёвок + Underwater Под водой + Ungroup Разгруппировать + Uniform Однородный + Unite Shapes at Overlap Объединить фигуры при перекрытии + Unlink Разъединить + Unlock Разблокировать + Unlocked Разблокировано + Unsaved Changes Несохранённые изменения + Unsharp Mask Нерезкое маскирование + Unsupported file format Неподдерживаемый формат файла + Unsupported format Неподдерживаемый формат + Up Вверх + Update Обновить + Updates Обновления + Upper Верхний + Use Custom Color Использовать свой цвет + Use File › Open to open an image. Используйте «Файл › Открыть», чтобы открыть изображение. + Use Selection In Source Использовать выделение в источнике + Use Selection In Target Использовать выделение в цели + Uses the foreground and background colors. Использует цвета переднего плана и фона. + Vanishing Point Точка схода + Variable Data Sets Наборы данных переменных + Vector Mask Hides Effects Векторная маска скрывает эффекты + Vertical Distortion Вертикальная дисторсия + Vertical Only Только по вертикали + Vertical Type Tool Инструмент «Вертикальный текст» + Vertical scale Масштаб по вертикали + Vertical type Вертикальный текст + Video Frames to Layers Видеокадры в слои + Vignette Amount Величина виньетки + Vignette Feather Растушёвка виньетки + Vignette Highlights Светлые тона виньетки + Vignette Midpoint Средняя точка виньетки + Vignette Roundness Округлость виньетки + Vignette Style Стиль виньетки + Vine Color Цвет лозы + Vine With Flowers Лоза с цветами + Vine With Leaves Лоза с листьями + Violent Сильный + W: Ш: + WIA Support Поддержка WIA + Warm Тёплый + Warming Filter (85) Согревающий фильтр (85) + Warming L B A Согревающий (LBA) + Warming81 Согревающий (81) + Warming85 Согревающий (85) + Warmth Теплота + Warning Предупреждение + Warp Text Деформация текста + Watercolor Акварель + Watercolour look: lighter interior, darker rims. Акварельный вид: светлее внутри, темнее по краям. + Wavelength Max Макс. длина волны + Wavelength Min Мин. длина волны + Waves Волны + Web Large Web (крупный) + Web Minimum Web (минимальный) + Web Most Common Web (наиболее распространённый) + WebP (lossless) WebP (без потерь) + Wetness Влажность + White Clip Отсечение белого + White Intensity Интенсивность белого + White Is High Белое — высокое + Whole Word Слово целиком + Wide Blurry Широкий размытый + Wide Sharp Широкий резкий + Wind Ветер + Window renderer: {renderer} Отрисовка окна: {renderer} + Working Рабочее + Working Cmyk Рабочий CMYK + Working Gray Рабочий: градации серого + Working Rgb Рабочий RGB + Working-cmyk Рабочий CMYK + Workspaces Рабочие среды + Wrap Оборачивание + Yellows Жёлтые тона + ZigZag Зигзаг + Zigzag Зигзаг + Zoom In (hold, drag to scrub) Увеличение (удерживайте, перетащите для прокрутки) + Zoom Out (hold) Уменьшение (удерживайте) + auto авто + blue синий + brushes кисти + fallback резерв + gradients градиенты + gray серый + green зелёный + layer mask маска слоя + manual вручную + none нет + off выключено + on включено + orange оранжевый + previous reading kept; the last re-read failed: {reason} сохранено предыдущее значение; последнее повторное чтение не удалось: {reason} + red красный + unknown неизвестно + unknown type неизвестный тип + vector mask векторная маска + violet фиолетовый + yellow жёлтый + {index} of {total} {index} из {total} + {label} is not available: {reason} {label} недоступно: {reason} + {message} (notes: {n}; {warning}) {message} (примечаний: {n}; {warning}) + {shortcut} assigned. {shortcut} назначено. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} уже используется командой {commands} и будет удалено из неё при нажатии «ОК». + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ навигация ↵ выполнить esc закрыть + ✓ Color Indicates Masked Areas ✓ Цветом обозначены маскированные области + ✓ Color Indicates Selected Areas ✓ Цветом обозначены выделенные области + + +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Очистить +@id edit.purge.undo Данные отмены +@id edit.stroke Обвести… +@id edit.transform.scale Масштабирование +@id file.close Закрыть +@id filter.sharpen.sharpen Резкость +@id layer.layerMask.apply Применить +@id select.all Выделить всё +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Импортирована {n} кисть из {file}|Импортированы {n} кисти из {file}|Импортировано {n} кистей из {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Импортирован {n} градиент из {file}|Импортированы {n} градиента из {file}|Импортировано {n} градиентов из {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Импортирован {n} узор из {file}|Импортированы {n} узора из {file}|Импортировано {n} узоров из {file} +cameraRaw Balance Баланс +cameraRaw Clarity Чёткость +cameraRaw Color Цвет +cameraRaw Color Mixer Смеситель цветов +cameraRaw Contrast Контраст +cameraRaw Curve Кривая +cameraRaw Dehaze Удаление дымки +cameraRaw Detail Детализация +cameraRaw Effects Эффекты +cameraRaw Exposure Экспозиция +cameraRaw Feather Растушёвка +cameraRaw Highlights Света +cameraRaw Luminance Яркость +cameraRaw Masking Маскирование +cameraRaw Midpoint Средняя точка +cameraRaw Midtones Средние тона +cameraRaw Radius Радиус +cameraRaw Roundness Округлость +cameraRaw Saturation Насыщенность +cameraRaw Shadows Тени +cameraRaw Size Размер +cameraRaw Temperature Температура +cameraRaw Texture Текстура +cameraRaw Tint Оттенок +cameraRaw Whites Белые точки +colorRange Invert Инвертировать +document Orientation Ориентация +layerLabel Magenta Пурпурный +layerLabel No Color Без цвета +layerLabel Red Красный +layers Fill Заливка +layers Fill: Заливка: +navigator Zoom Масштаб +shape Appearance Оформление + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Автоматическое направление абзаца + Left-to-right paragraph direction Направление абзаца слева направо + Right-to-left paragraph direction Направление абзаца справа налево diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c303d62954..c93c45be31 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index 49bc3bc79b..25cc9fec4f 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -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)); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7f34282232..106385d1b2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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ü diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 734a4b394f..46f1a3057f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Напрямок абзацу справа наліво diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e08a2d906d..2123ec228f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4f4c95d38b..b47afe56f8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 从右到左的段落方向 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1ec5812731..db55fdf765 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 由右至左的段落方向 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index db0a6f71b5..a255fe094b 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -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")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bdb1e6915f..36cce97cac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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. diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 6da6500d3a..2beef91f94 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -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) } } diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index ed6d3d8282..e0030d18f0 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1797,7 +1797,7 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, 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)); diff --git a/crates/ui-egui/src/props_layout_tests.rs b/crates/ui-egui/src/props_layout_tests.rs index 74fd29121a..62e9423f44 100644 --- a/crates/ui-egui/src/props_layout_tests.rs +++ b/crates/ui-egui/src/props_layout_tests.rs @@ -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 diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 077eb9bc5e..61de47848f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, + /// 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, + pub resize: Option, /// 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, + /// 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, /// Inline type editing session, if any. #[serde(default)] pub text_edit: Option, @@ -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, diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index dfe7abf103..d0399ab4bc 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -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), diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 6e20d99b15..a9b1d23590 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -13,6 +13,7 @@ struct State { button: egui::Id, popup: egui::Id, preview: FontPreview, + recents: Vec, } 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) -> Harness<'static, State> { // Enough families that the unfiltered menu hits its maximum height and scrolls. let mut families: Vec = (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 = (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 = [("MS Gothic", "MS ゴシック"), ("Microsoft YaHei", "微软雅黑")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(); let rows = |labels: Option<&HashMap>, 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 = ["Arial", "Baskerville", "Courier", "Zeta Sans"].iter().map(|s| s.to_string()).collect(); + let recents: Vec = vec!["Zeta Sans".into(), "Courier".into()]; + let rows = |q: &str| -> Vec { 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)); diff --git a/crates/ui-egui/src/type_font_preview.rs b/crates/ui-egui/src/type_font_preview.rs index 4c652ed7ce..7f420d2494 100644 --- a/crates/ui-egui/src/type_font_preview.rs +++ b/crates/ui-egui/src/type_font_preview.rs @@ -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::(hover_id); d.remove::(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")); + } +} diff --git a/crates/ui-egui/src/type_panels_ui.rs b/crates/ui-egui/src/type_panels_ui.rs index 86ef663209..65e28e04be 100644 --- a/crates/ui-egui/src/type_panels_ui.rs +++ b/crates/ui-egui/src/type_panels_ui.rs @@ -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(); } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 5b48b04f38..119310672f 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -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 { + 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 { + 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> { /// 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>, 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>, 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 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 { 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] diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 3760d504a1..e61258bbdd 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -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::>(), ["frac", "swsh"]); + assert_eq!(c.variations.iter().map(|v| (v.axis.as_str(), v.value)).collect::>(), [("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::>(), [(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]); +} diff --git a/docs/gaps.md b/docs/gaps.md index 3e1b096ede..3cfbc47f43 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/localization-it.md b/docs/localization-it.md index 7b0444410e..f983c499e4 100644 --- a/docs/localization-it.md +++ b/docs/localization-it.md @@ -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). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0b2be85595..21108e4549 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -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)) | diff --git a/docs/localization-ru.md b/docs/localization-ru.md new file mode 100644 index 0000000000..ea0eff64df --- /dev/null +++ b/docs/localization-ru.md @@ -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. diff --git a/docs/localization.md b/docs/localization.md index 36e09f8a55..5a1355057c 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -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 diff --git a/docs/scorecard.md b/docs/scorecard.md index 30255628f2..0faa2ed649 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9946f3b807..cd5be57534 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 | diff --git a/scorecard/type.toml b/scorecard/type.toml index 88aab270bb..bb6b1bb264 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -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"