mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 03:54:35 +00:00
Add text: gray or CMYK colour, painted with g or k (#6440)
page_add_text and content_update took only RGB, so text added to a CMYK print file painted 0 0 0 rg, rich black on press. They now also take gray (0 to 1) or cmyk ([c, m, y, k]), painted with g or k and kept in /PCAdded as /Gray or /CMYK beside /Color, which holds the matching look. An ink applies only while the colour still shows its look: picking another colour, also in the Format panel, drops it for good. RGB text is written byte for byte as before, and a stored ink that doesn't match its colour is ignored.
This commit is contained in:
16 files changed
+400
-25
No files matched your search
@@ -26,6 +26,11 @@ its undo/redo history.
|
||||
`page_add_text`, `content_update` and `text_edit` take per-line `aligns`. An `"inherit"` entry
|
||||
follows the box-level `align`; legacy `null` entries remain accepted with the same meaning.
|
||||
|
||||
`page_add_text` and `content_update` take one of `color` (`#RRGGBB` or a name, painted in
|
||||
DeviceRGB), `gray` (0 black to 1 white, DeviceGray `g`) and `cmyk` (`[c, m, y, k]`, each 0 to 1,
|
||||
DeviceCMYK `k`), so text added to a print file can be 100 K black. `content_list` reports `gray` or
|
||||
`cmyk` for such text.
|
||||
|
||||
`Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent).
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! move/resize/retype/restyle it, delete it. Geometry is in points from the top-left of the
|
||||
//! displayed page (y down), like every other tool.
|
||||
|
||||
use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign};
|
||||
use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign, TextInk};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::comments::parse_color;
|
||||
@@ -30,6 +30,27 @@ fn rect_arg(a: &Args, key: &str) -> Result<Option<[f64; 4]>> {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
const INK_ARGS: &str = "give one of color (#RRGGBB or a name), gray (0 black to 1 white) and cmyk ([c, m, y, k], each 0 to 1)";
|
||||
|
||||
/// `gray` or `cmyk`: the text painted in DeviceGray or DeviceCMYK rather than RGB. At most one of
|
||||
/// them and `color`.
|
||||
fn ink_arg(a: &Args) -> Result<Option<TextInk>> {
|
||||
if ["color", "gray", "cmyk"].iter().filter(|k| a.get(k).is_some()).count() > 1 {
|
||||
return Err(bad(INK_ARGS));
|
||||
}
|
||||
let unit = |v: Option<f64>, key: &str| v.filter(|v| (0.0..=1.0).contains(v)).ok_or_else(|| bad(format!("{key} takes numbers from 0 to 1")));
|
||||
if let Some(g) = a.get("gray") {
|
||||
return Ok(Some(TextInk::Gray(unit(g.as_f64(), "gray")?)));
|
||||
}
|
||||
let Some(c) = a.get("cmyk") else { return Ok(None) };
|
||||
let parts = c.as_array().filter(|p| p.len() == 4).ok_or_else(|| bad("cmyk must be [c, m, y, k]"))?;
|
||||
let mut cmyk = [0.0; 4];
|
||||
for (out, v) in cmyk.iter_mut().zip(parts) {
|
||||
*out = unit(v.as_f64(), "cmyk")?;
|
||||
}
|
||||
Ok(Some(TextInk::Cmyk(cmyk)))
|
||||
}
|
||||
|
||||
/// Apply the style arguments to `t`.
|
||||
fn style(a: &Args, t: &mut AddedText) -> Result<()> {
|
||||
if let Some(s) = a.opt_num("size")? {
|
||||
@@ -49,8 +70,12 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
|
||||
if let Some(i) = a.opt_bool("italic")? {
|
||||
t.italic = i;
|
||||
}
|
||||
if let Some(c) = a.opt_str("color")? {
|
||||
t.color = parse_color(c)?;
|
||||
if let Some(ink) = ink_arg(a)? {
|
||||
t.set_ink(Some(ink));
|
||||
} else if let Some(c) = a.opt_str("color")? {
|
||||
// An RGB colour paints in RGB, even one that is the ink's look.
|
||||
t.set_ink(None);
|
||||
t.set_color(parse_color(c)?);
|
||||
}
|
||||
if let Some(al) = a.opt_str("align")? {
|
||||
t.align = parse_align(al)?;
|
||||
@@ -124,6 +149,12 @@ impl Automation {
|
||||
v["bold"] = json!(t.bold);
|
||||
v["italic"] = json!(t.italic);
|
||||
v["char_spacing"] = json!(t.char_spacing);
|
||||
// Text painted in a print colour space says so; RGB text has neither.
|
||||
match t.painted_ink() {
|
||||
Some(TextInk::Gray(g)) => v["gray"] = json!(g),
|
||||
Some(TextInk::Cmyk(c)) => v["cmyk"] = json!(c),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
AddedContent::Image(_) => v["type"] = json!("image"),
|
||||
}
|
||||
@@ -205,8 +236,8 @@ impl Automation {
|
||||
if a.get("text").is_some() {
|
||||
return Err(bad("images have no text"));
|
||||
}
|
||||
if ["font", "size", "bold", "italic", "color", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
|
||||
return Err(bad("font, size, bold, italic, color, align, aligns and char_spacing apply to text"));
|
||||
if ["font", "size", "bold", "italic", "color", "gray", "cmyk", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
|
||||
return Err(bad("font, size, bold, italic, color, gray, cmyk, align, aligns and char_spacing apply to text"));
|
||||
}
|
||||
if let Some(deg) = a.opt_int("rotate")? {
|
||||
if deg.rem_euclid(90) != 0 {
|
||||
|
||||
@@ -718,13 +718,13 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("content_list", "List added content", "Text and images added with page_add_text/page_add_image (or Edit a PDF ▸ Add content), per page with a 1-based index, rect (points from the top-left of the page), and text style.")
|
||||
t("content_list", "List added content", "Text and images added with page_add_text/page_add_image (or Edit a PDF ▸ Add content), per page with a 1-based index, rect (points from the top-left of the page), and text style; text painted in gray or CMYK also has gray (its level) or cmyk ([c, m, y, k]).")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t(
|
||||
"page_add_text",
|
||||
"Add text to a page",
|
||||
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right, char_spacing (points after each character). Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.",
|
||||
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name, painted in RGB) or, for print, gray (0 black to 1 white, painted in DeviceGray) or cmyk ([c, m, y, k], each 0 to 1, painted in DeviceCMYK; [0, 0, 0, 1] is 100 K black), one of the three, align left/center/right, char_spacing (points after each character). Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.",
|
||||
)
|
||||
.cmd("edit.text")
|
||||
.with(schema(
|
||||
@@ -735,6 +735,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
|
||||
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
|
||||
"gray": { "type": "number", "minimum": 0, "maximum": 1, "description": "DeviceGray level, 0 black to 1 white (instead of color)." },
|
||||
"cmyk": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 1 }, "minItems": 4, "maxItems": 4, "description": "DeviceCMYK [c, m, y, k], each 0 to 1 (instead of color); [0, 0, 0, 1] is 100 K black." },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
@@ -751,13 +753,15 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "path": { "type": "string" }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 } }),
|
||||
&["doc", "page", "path"],
|
||||
)),
|
||||
t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align, char_spacing in points) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.")
|
||||
t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color or gray or cmyk as page_add_text takes them, align, char_spacing in points) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "text": { "type": "string" },
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
|
||||
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
|
||||
"gray": { "type": "number", "minimum": 0, "maximum": 1, "description": "DeviceGray level, 0 black to 1 white (instead of color)." },
|
||||
"cmyk": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 1 }, "minItems": 4, "maxItems": 4, "description": "DeviceCMYK [c, m, y, k], each 0 to 1 (instead of color); [0, 0, 0, 1] is 100 K black." },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
|
||||
@@ -5214,6 +5214,59 @@ fn adding_content_through_tools() {
|
||||
assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
/// #6440: `page_add_text` paints text in CMYK or gray for a print file, so its black is 100 K
|
||||
/// (`0 0 0 1 k`) rather than DeviceRGB; `content_update` restyles it either way.
|
||||
#[test]
|
||||
fn added_text_takes_a_cmyk_or_gray_ink_through_tools() {
|
||||
let dir = workdir("content-ink");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "PROOF", "at": [20, 20], "cmyk": [0, 0, 0, 1] }));
|
||||
ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Note", "at": [20, 80], "gray": 0.5 }));
|
||||
let items = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone();
|
||||
assert_eq!((&items[0]["cmyk"], &items[1]["gray"]), (&json!([0.0, 0.0, 0.0, 1.0]), &json!(0.5)));
|
||||
// The item streams of the saved file.
|
||||
let saved_items = |a: &mut Automation, name: &str| -> Vec<String> {
|
||||
ok(a, "doc_save", json!({ "doc": doc, "path": name }));
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap();
|
||||
let page = &pdfcraft_model::pages(&saved)[0];
|
||||
let list = saved.resolve(page.dict.get(b"Contents").unwrap()).as_array().cloned().unwrap();
|
||||
list.iter()
|
||||
.filter_map(|c| match &*saved.resolve(c) {
|
||||
pdfcraft_cos::Object::Stream(s) if s.dict.name(b"PCMark") == Some(&b"Added"[..]) => {
|
||||
Some(String::from_utf8_lossy(&s.decoded().unwrap()).into_owned())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let streams = saved_items(&mut a, "ink.pdf");
|
||||
assert!(streams[0].contains("0 0 0 1 k") && !streams[0].contains("rg"), "{}", streams[0]);
|
||||
assert!(streams[1].contains("0.5 g") && !streams[1].contains("rg"), "{}", streams[1]);
|
||||
// Restyled: to RGB with color, and back to CMYK.
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "color": "#000000" }));
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "cmyk": [1, 0, 0, 0] }));
|
||||
let items = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone();
|
||||
assert_eq!((&items[0]["cmyk"], items[1].get("gray")), (&json!([1.0, 0.0, 0.0, 0.0]), None));
|
||||
let streams = saved_items(&mut a, "restyled.pdf");
|
||||
assert!(streams[0].contains("1 0 0 0 k") && streams[1].contains("0 0 0 rg"), "{streams:?}");
|
||||
// One colour at a time, four numbers from 0 to 1.
|
||||
for bad in [
|
||||
json!({ "color": "red", "cmyk": [0, 0, 0, 1] }),
|
||||
json!({ "gray": 0.5, "cmyk": [0, 0, 0, 1] }),
|
||||
json!({ "gray": 1.5 }),
|
||||
json!({ "cmyk": [0, 0, 1] }),
|
||||
json!({ "cmyk": [0, 0, 0, 1, 0] }),
|
||||
json!({ "cmyk": [0, "x", 0, 1] }),
|
||||
json!({ "cmyk": [0, 0, 0, -0.1] }),
|
||||
] {
|
||||
let mut args = json!({ "doc": doc, "page": 1, "text": "x", "at": [20, 200] });
|
||||
args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
|
||||
assert!(matches!(a.call("page_add_text", &args), Err(ToolError::InvalidArgs(_))), "{bad}");
|
||||
}
|
||||
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 2, "nothing refused was added");
|
||||
}
|
||||
|
||||
/// Edit ▸ Copy and Paste headlessly (#2636): an added text box or image, as it is, onto its own
|
||||
/// page beside it, onto a point, and into another document.
|
||||
#[test]
|
||||
|
||||
@@ -31,7 +31,7 @@ use layout::Piece;
|
||||
pub(crate) use layout::WRAP_CALLS;
|
||||
pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width};
|
||||
pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content};
|
||||
pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan};
|
||||
pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, Ink, MarkerScan};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Content {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::layout::{
|
||||
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, norm, shaped_layout, text_rect, thai_error, wrapped,
|
||||
};
|
||||
use super::types::{AddedText, Align, Family};
|
||||
use super::types::{AddedText, Align, Family, Ink};
|
||||
use super::{
|
||||
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
|
||||
align_code, literal, matrix_number, n, shaped_glyph, win_ansi,
|
||||
@@ -105,8 +105,7 @@ pub(super) fn draw_laid_shaped(
|
||||
fonts.set(name.clone().into_bytes(), Object::Ref(font));
|
||||
names.push(name);
|
||||
}
|
||||
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
|
||||
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
|
||||
out.extend(format!("BT {}\n", t.fill_op()).bytes());
|
||||
if t.char_spacing != 0.0 {
|
||||
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
|
||||
}
|
||||
@@ -293,8 +292,7 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict
|
||||
}
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let r = text_rect(t);
|
||||
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
|
||||
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
|
||||
out.extend(format!("BT /{name} {} Tf {}\n", n(t.size), t.fill_op()).bytes());
|
||||
if t.char_spacing != 0.0 {
|
||||
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
|
||||
}
|
||||
@@ -355,6 +353,12 @@ pub(super) fn params(c: &Content) -> Dict {
|
||||
d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic)));
|
||||
d.set(b"Size".to_vec(), Object::Real(t.size));
|
||||
d.set(b"Color".to_vec(), arr(&t.color));
|
||||
// The ink beside its RGB look, which a reader that knows no ink keeps showing.
|
||||
match t.painted_ink() {
|
||||
Some(Ink::Gray(g)) => d.set(b"Gray".to_vec(), Object::Real(g)),
|
||||
Some(Ink::Cmyk(c)) => d.set(b"CMYK".to_vec(), arr(&c)),
|
||||
None => {}
|
||||
}
|
||||
d.set(b"Align".to_vec(), Object::Int(align_code(t.align)));
|
||||
// Each paragraph's own alignment, or null where it follows /Align.
|
||||
let aligns = crate::paragraph_align::tidy(t.aligns.clone(), t.align);
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
use super::draw::{draw, params};
|
||||
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
|
||||
use super::types::{Added, AddedImage, AddedText, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
|
||||
use super::types::{Added, AddedImage, AddedText, CHAR_SPACING_LIMIT, Family, Ink, MarkerScan, TAG};
|
||||
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, align_from, check, contents, page_list, place_tagged};
|
||||
|
||||
/// A text item's gray or CMYK ink. Items written before inks, and an ink out of range, have none:
|
||||
/// the text paints in its RGB `/Color`.
|
||||
fn ink(doc: &Document, d: &Dict) -> Option<Ink> {
|
||||
let ink = match (d.get(b"Gray").map(|g| doc.resolve(g)).and_then(|g| g.as_f64()), d.get(b"CMYK")) {
|
||||
(Some(g), _) => Ink::Gray(g),
|
||||
(None, Some(c)) => Ink::Cmyk(nums(doc, Some(c)).try_into().ok()?),
|
||||
(None, None) => return None,
|
||||
};
|
||||
Some(ink).filter(|i| i.valid())
|
||||
}
|
||||
|
||||
fn parse(doc: &Document, d: &Dict) -> Option<Content> {
|
||||
let r = nums(doc, d.get(b"Rect"));
|
||||
let rect: [f64; 4] = r.try_into().ok()?;
|
||||
@@ -21,7 +32,7 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
|
||||
.map(|a| a.iter().take(crate::paragraph_align::count(&text)).map(|v| v.as_int().map(|c| align_from(Some(c)))).collect())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(Content::Text(AddedText {
|
||||
let mut t = AddedText {
|
||||
rect,
|
||||
text,
|
||||
family,
|
||||
@@ -29,11 +40,15 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
|
||||
italic,
|
||||
size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0),
|
||||
color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] },
|
||||
ink: ink(doc, d),
|
||||
align,
|
||||
aligns: crate::paragraph_align::tidy(aligns, align),
|
||||
// Items written before character spacing have none.
|
||||
char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),
|
||||
}))
|
||||
};
|
||||
// An ink whose look isn't the stored colour (a file edited by hand) is none.
|
||||
t.drop_stale_ink();
|
||||
Some(Content::Text(t))
|
||||
}
|
||||
b"Image" => {
|
||||
let crop = nums(doc, d.get(b"Crop"));
|
||||
@@ -138,6 +153,9 @@ fn validate(c: &Content) -> Result<(), EditError> {
|
||||
"the character spacing must be between -{CHAR_SPACING_LIMIT} and {CHAR_SPACING_LIMIT} points"
|
||||
)));
|
||||
}
|
||||
if t.ink.is_some_and(|i| !i.valid()) {
|
||||
return Err(EditError::Invalid("a gray level and each CMYK value must be from 0 to 1".into()));
|
||||
}
|
||||
if (r[2] - r[0]).abs() < 1.0 {
|
||||
return Err(EditError::Invalid("the text box is too narrow".into()));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::layout::norm;
|
||||
use super::{Content, MarkKind, ObjRef, Std14};
|
||||
use super::{Content, MarkKind, ObjRef, Std14, n};
|
||||
|
||||
pub(super) const TAG: &str = "Added";
|
||||
|
||||
@@ -90,6 +90,50 @@ pub enum Align {
|
||||
Justify,
|
||||
}
|
||||
|
||||
/// A device colour added text is painted in instead of RGB, for print files: a text's black in
|
||||
/// CMYK stays 100 K rather than a DeviceRGB black the printer separates into rich black.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Ink {
|
||||
/// A DeviceGray level, from 0 (black) to 1 (white): painted with `g`.
|
||||
Gray(f64),
|
||||
/// DeviceCMYK `[c, m, y, k]`, each from 0 to 1 (`[0, 0, 0, 1]` is 100 K): painted with `k`.
|
||||
Cmyk([f64; 4]),
|
||||
}
|
||||
|
||||
impl Ink {
|
||||
/// Whether every component is a number from 0 to 1.
|
||||
pub fn valid(self) -> bool {
|
||||
self.components().iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))
|
||||
}
|
||||
|
||||
fn components(&self) -> &[f64] {
|
||||
match self {
|
||||
Ink::Gray(g) => std::slice::from_ref(g),
|
||||
Ink::Cmyk(c) => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// How the ink looks on screen: gray is the same level in each of red, green and blue
|
||||
/// (ISO 32000-2 §10.4.2.2), CMYK takes each colour plus black from 1 (§10.4.2.5).
|
||||
pub fn rgb(self) -> [f64; 3] {
|
||||
match self {
|
||||
Ink::Gray(g) => [g.clamp(0.0, 1.0); 3],
|
||||
Ink::Cmyk([c, m, y, k]) => [c, m, y].map(|v| 1.0 - (v + k).clamp(0.0, 1.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator that makes this ink the fill colour, without a line end.
|
||||
pub(super) fn fill_op(self) -> String {
|
||||
match self {
|
||||
Ink::Gray(g) => format!("{} g", n(g.clamp(0.0, 1.0))),
|
||||
Ink::Cmyk(c) => {
|
||||
let [c, m, y, k] = c.map(|v| v.clamp(0.0, 1.0));
|
||||
format!("{} {} {} {} k", n(c), n(m), n(y), n(k))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AddedText {
|
||||
/// The text box in display space; its width wraps the text and its top is the first line's
|
||||
@@ -100,7 +144,14 @@ pub struct AddedText {
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub size: f64,
|
||||
/// The RGB colour: what the text is painted in, unless it has an [`AddedText::ink`] this is
|
||||
/// the look of.
|
||||
pub color: [f64; 3],
|
||||
/// A gray or CMYK ink the text is painted in, with `color` its look ([`Ink::rgb`]). Set it
|
||||
/// with [`AddedText::set_ink`] and the colour with [`AddedText::set_color`], which drops an
|
||||
/// ink the colour no longer shows; one left behind by setting `color` directly paints nothing
|
||||
/// ([`AddedText::painted_ink`]) until [`AddedText::drop_stale_ink`] forgets it.
|
||||
pub ink: Option<Ink>,
|
||||
pub align: Align,
|
||||
/// Extra space after every character, in points: the PDF's character spacing (`Tc`).
|
||||
pub char_spacing: f64,
|
||||
@@ -114,6 +165,41 @@ impl AddedText {
|
||||
pub fn paragraph_align(&self, i: usize) -> Align {
|
||||
crate::paragraph_align::align_of(&self.aligns, i, self.align)
|
||||
}
|
||||
|
||||
/// Paint the text in `ink` (its look becomes [`AddedText::color`]), or in RGB with `None`.
|
||||
pub fn set_ink(&mut self, ink: Option<Ink>) {
|
||||
if let Some(i) = ink {
|
||||
self.color = i.rgb();
|
||||
}
|
||||
self.ink = ink;
|
||||
}
|
||||
|
||||
/// Paint the text in RGB `rgb`. Its ink stays only when `rgb` is the ink's own look; once
|
||||
/// gone it stays gone, so picking that look again later paints in RGB.
|
||||
pub fn set_color(&mut self, rgb: [f64; 3]) {
|
||||
self.color = rgb;
|
||||
self.drop_stale_ink();
|
||||
}
|
||||
|
||||
/// Forget an ink `color` no longer shows, so two styles that paint the same compare equal.
|
||||
pub fn drop_stale_ink(&mut self) {
|
||||
self.ink = self.painted_ink();
|
||||
}
|
||||
|
||||
/// The ink the text is painted in, or `None` for RGB: the one it has while `color` is still
|
||||
/// its look.
|
||||
pub fn painted_ink(&self) -> Option<Ink> {
|
||||
// Within rounding: a look read back from a file, or from a colour picker, is the same.
|
||||
self.ink.filter(|i| i.rgb().iter().zip(self.color).all(|(a, b)| (a - b).abs() < 1e-6))
|
||||
}
|
||||
|
||||
/// The operator that sets the text's fill colour, without a line end.
|
||||
pub(super) fn fill_op(&self) -> String {
|
||||
match self.painted_ink() {
|
||||
Some(ink) => ink.fill_op(),
|
||||
None => crate::marks::rgb(self.color),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AddedText {
|
||||
@@ -126,6 +212,7 @@ impl Default for AddedText {
|
||||
italic: false,
|
||||
size: 12.0,
|
||||
color: [0.0; 3],
|
||||
ink: None,
|
||||
align: Align::Left,
|
||||
aligns: Vec::new(),
|
||||
char_spacing: 0.0,
|
||||
|
||||
@@ -61,8 +61,8 @@ pub use text::{
|
||||
};
|
||||
pub mod added;
|
||||
pub use added::{
|
||||
Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, list_added,
|
||||
scan_markers, update_content,
|
||||
Added, AddedImage, AddedText, Align, Content, Family, Ink, MarkerScan, add_content, delete_content, first_undrawable, isolate_content,
|
||||
list_added, scan_markers, update_content,
|
||||
};
|
||||
|
||||
mod apply;
|
||||
|
||||
@@ -1048,6 +1048,107 @@ fn added_text_and_images_are_page_content_that_stays_editable() {
|
||||
assert!(marks_present(&doc).is_empty());
|
||||
}
|
||||
|
||||
/// #6440: added text in a print file can be gray or CMYK, painted with `g` / `k`, so its black
|
||||
/// stays 100 K. The ink survives save and reopen; another colour drops it for good.
|
||||
#[test]
|
||||
fn gray_and_cmyk_added_text_writes_g_and_k_until_it_is_recoloured() {
|
||||
use crate::added::Ink;
|
||||
let item_stream = |doc: &Document, i: usize| {
|
||||
let obj = list_added(doc)[i].obj;
|
||||
let Object::Stream(s) = &*doc.get(obj) else { panic!("an item is a stream") };
|
||||
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
|
||||
};
|
||||
let mut doc = fixture();
|
||||
let base = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Proof".into(), size: 14.0, ..AddedText::default() };
|
||||
let mut black = base.clone();
|
||||
black.set_ink(Some(Ink::Cmyk([0.0, 0.0, 0.0, 1.0])));
|
||||
assert_eq!(black.color, [0.0; 3], "100 K looks black on screen");
|
||||
let mut gray = AddedText { rect: [72.0, 400.0, 300.0, 500.0], ..base.clone() };
|
||||
gray.set_ink(Some(Ink::Gray(0.25)));
|
||||
add_content(&mut doc, 0, &Content::Text(black)).unwrap();
|
||||
add_content(&mut doc, 0, &Content::Text(gray)).unwrap();
|
||||
add_content(&mut doc, 0, &Content::Text(AddedText { rect: [72.0, 200.0, 300.0, 300.0], ..base.clone() })).unwrap();
|
||||
|
||||
let doc = reopen(&doc);
|
||||
let (k, g, rgb) = (item_stream(&doc, 0), item_stream(&doc, 1), item_stream(&doc, 2));
|
||||
assert!(k.contains("/PCFHelvetica 14 Tf 0 0 0 1 k\n") && !k.contains("rg"), "{k}");
|
||||
assert!(g.contains("/PCFHelvetica 14 Tf 0.25 g\n") && !g.contains("rg"), "{g}");
|
||||
assert!(rgb.contains("/PCFHelvetica 14 Tf 0 0 0 rg\n"), "RGB text paints as before: {rgb}");
|
||||
let inks: Vec<Option<Ink>> = list_added(&doc).iter().map(|a| if let Content::Text(t) = &a.content { t.painted_ink() } else { None }).collect();
|
||||
assert_eq!(inks, [Some(Ink::Cmyk([0.0, 0.0, 0.0, 1.0])), Some(Ink::Gray(0.25)), None]);
|
||||
|
||||
// Another colour drops the ink, and picking the ink's look again later doesn't bring it back;
|
||||
// picking that look while the ink is there keeps it.
|
||||
let Content::Text(cmyk) = list_added(&doc)[0].content.clone() else { panic!() };
|
||||
let mut same = cmyk.clone();
|
||||
same.set_color([0.0; 3]);
|
||||
assert_eq!(same, cmyk, "black on 100 K stays 100 K");
|
||||
let mut recoloured = cmyk.clone();
|
||||
recoloured.set_color([1.0, 0.0, 0.0]);
|
||||
assert_eq!(recoloured.ink, None);
|
||||
recoloured.set_color([0.0; 3]);
|
||||
assert_eq!((recoloured.ink, recoloured.painted_ink()), (None, None), "the ink stays gone");
|
||||
|
||||
// Set directly, a colour paints in RGB too, and the item reads back without the ink: what a
|
||||
// draft with its stale ink dropped compares equal to.
|
||||
let mut doc = doc;
|
||||
let red = AddedText { color: [1.0, 0.0, 0.0], ..cmyk };
|
||||
update_content(&mut doc, 0, 0, &Content::Text(red.clone())).unwrap();
|
||||
let stream = item_stream(&doc, 0);
|
||||
assert!(stream.contains("1 0 0 rg") && !stream.contains(" k\n"), "{stream}");
|
||||
let mut settled = red;
|
||||
settled.drop_stale_ink();
|
||||
for doc in [&doc, &reopen(&doc)] {
|
||||
let Content::Text(back) = list_added(doc)[0].content.clone() else { panic!() };
|
||||
// The stored box is the text's own height.
|
||||
assert_eq!(back, AddedText { rect: back.rect, ..settled.clone() });
|
||||
}
|
||||
|
||||
// CMYK looks as ISO 32000-2 §10.4.2.5 maps it to RGB; an ink out of range is refused.
|
||||
assert_eq!(Ink::Cmyk([0.5, 0.0, 1.0, 0.25]).rgb(), [0.25, 0.75, 0.0]);
|
||||
let mut wrong = base;
|
||||
wrong.ink = Some(Ink::Gray(1.5));
|
||||
assert!(add_content(&mut doc, 0, &Content::Text(wrong)).is_err());
|
||||
}
|
||||
|
||||
/// #6440: a file edited by hand paints its added text in RGB unless the stored gray or CMYK is
|
||||
/// in range, the right length and still the look of `/Color`; nothing in it panics.
|
||||
#[test]
|
||||
fn a_hand_edited_ink_reads_back_only_when_it_is_the_stored_colours_look() {
|
||||
use crate::added::Ink;
|
||||
let mut doc = fixture();
|
||||
add_content(&mut doc, 0, &Content::Text(AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Proof".into(), ..AddedText::default() })).unwrap();
|
||||
let obj = list_added(&doc)[0].obj;
|
||||
let real = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect());
|
||||
let read = |entries: &[(&[u8], Object)]| {
|
||||
let mut edited = doc.clone();
|
||||
let Object::Stream(mut s) = (*edited.get(obj)).clone() else { panic!("an item is a stream") };
|
||||
let mut params = s.dict.get(b"PCAdded").and_then(|p| p.as_dict().cloned()).unwrap();
|
||||
for (key, value) in entries {
|
||||
params.set(key.to_vec(), value.clone());
|
||||
}
|
||||
s.dict.set(b"PCAdded".to_vec(), Object::Dict(params));
|
||||
edited.set(obj, Object::Stream(s));
|
||||
let Content::Text(t) = list_added(&edited)[0].content.clone() else { panic!() };
|
||||
t.ink
|
||||
};
|
||||
// As PDFCraft writes it: the ink stands.
|
||||
assert_eq!(read(&[(b"Color", real(&[0.5; 3])), (b"Gray", Object::Real(0.5))]), Some(Ink::Gray(0.5)));
|
||||
assert_eq!(read(&[(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 0.0, 1.0]))]), Some(Ink::Cmyk([0.0, 0.0, 0.0, 1.0])));
|
||||
for (what, entries) in [
|
||||
("a colour the ink isn't the look of", vec![(&b"Color"[..], real(&[1.0, 0.0, 0.0])), (b"CMYK", real(&[0.0, 0.0, 0.0, 1.0]))]),
|
||||
("three CMYK numbers", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 1.0]))]),
|
||||
("five CMYK numbers", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 0.0, 1.0, 0.0]))]),
|
||||
("CMYK not an array", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", Object::Real(1.0))]),
|
||||
("gray out of range", vec![(b"Color", real(&[1.0; 3])), (b"Gray", Object::Real(2.0))]),
|
||||
("gray not a number", vec![(b"Color", real(&[0.0; 3])), (b"Gray", Object::name("Black"))]),
|
||||
("gray NaN", vec![(b"Color", real(&[0.0; 3])), (b"Gray", Object::Real(f64::NAN))]),
|
||||
("CMYK infinite", vec![(b"Color", real(&[0.0; 3])), (b"CMYK", real(&[0.0, 0.0, 0.0, f64::INFINITY]))]),
|
||||
] {
|
||||
assert_eq!(read(&entries), None, "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #4464: a page left with one added item draws that item and nothing else.
|
||||
#[test]
|
||||
fn an_isolated_item_is_all_that_is_left_on_its_page() {
|
||||
|
||||
@@ -91,7 +91,8 @@ pub use pdfcraft_edit::added::{
|
||||
};
|
||||
pub use pdfcraft_edit::paragraph_align;
|
||||
pub use pdfcraft_edit::{
|
||||
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
|
||||
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, Ink as TextInk,
|
||||
MarkKind, Watermark,
|
||||
};
|
||||
pub use pdfcraft_forms::{
|
||||
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
|
||||
|
||||
@@ -4182,6 +4182,7 @@ fn pasted_added_content_keeps_its_style_and_picture() {
|
||||
italic: true,
|
||||
size: 12.0,
|
||||
color: [0.8, 0.1, 0.2],
|
||||
ink: None,
|
||||
align: TextAlign::Center,
|
||||
aligns: Vec::new(),
|
||||
char_spacing: 0.0,
|
||||
@@ -8112,6 +8113,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() {
|
||||
italic: false,
|
||||
size: 12.0,
|
||||
color: [0.0, 0.0, 0.0],
|
||||
ink: None,
|
||||
align: TextAlign::Left,
|
||||
aligns: Vec::new(),
|
||||
char_spacing: 0.0,
|
||||
@@ -8153,6 +8155,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() {
|
||||
italic: false,
|
||||
size: 12.0,
|
||||
color: [0.0, 0.0, 0.0],
|
||||
ink: None,
|
||||
align: TextAlign::Left,
|
||||
aligns: Vec::new(),
|
||||
char_spacing: 0.0,
|
||||
|
||||
@@ -391,3 +391,66 @@ fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alo
|
||||
assert!(app.save_active(SaveTarget::InPlace));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #6440: recolouring gray or CMYK text in its open box drops the ink at once. Finishing the box
|
||||
/// then adds no empty Undo step (the draft equals the item the page reads back), picking the
|
||||
/// ink's own look again in the same box paints RGB rather than bringing the ink back, and new
|
||||
/// boxes don't take the ink.
|
||||
#[test]
|
||||
fn recolouring_print_ink_text_in_its_box_drops_the_ink_without_an_empty_step() {
|
||||
// 100 K text, open in its box.
|
||||
let open = || {
|
||||
let mut app = crate::PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("a.pdf", None, one_page_pdf()).unwrap();
|
||||
let mut text = pdfcraft_engine::AddedText { rect: [20.0, 200.0, 180.0, 220.0], text: "Proof".into(), ..Default::default() };
|
||||
text.set_ink(Some(pdfcraft_engine::TextInk::Cmyk([0.0, 0.0, 0.0, 1.0])));
|
||||
assert!(app.apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text }));
|
||||
let stored = item(&app);
|
||||
assert!(stored.painted_ink().is_some());
|
||||
app.views[0].content.draft = Some(crate::content_ui::TextDraft {
|
||||
page: 0,
|
||||
index: Some(0),
|
||||
rect: stored.rect,
|
||||
text: stored.text.clone(),
|
||||
style: stored,
|
||||
focus: true,
|
||||
selection: None,
|
||||
typing_from: None,
|
||||
});
|
||||
app
|
||||
};
|
||||
fn item(app: &crate::PdfCraftApp) -> pdfcraft_engine::AddedText {
|
||||
match &app.session.get(app.views[0].id).unwrap().added()[0].content {
|
||||
pdfcraft_engine::AddedContent::Text(t) => t.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
// The box's style with another colour, set as the Format panel hands it over.
|
||||
let recolour = |app: &mut crate::PdfCraftApp, color: [f64; 3]| {
|
||||
let style = app.views[0].content.draft.as_ref().map(|d| d.style.clone()).unwrap();
|
||||
app.restyle_draft(0, pdfcraft_engine::AddedText { color, ..style });
|
||||
};
|
||||
let finish = |app: &mut crate::PdfCraftApp| {
|
||||
let added = app.session.get(app.views[0].id).unwrap().added().to_vec();
|
||||
app.views[0].content.finish_draft(&added)
|
||||
};
|
||||
|
||||
// Red, then Done.
|
||||
let mut app = open();
|
||||
recolour(&mut app, [1.0, 0.0, 0.0]);
|
||||
assert_eq!(item(&app).ink, None, "the page paints red in RGB");
|
||||
let finished = finish(&mut app);
|
||||
assert!(finished.is_none(), "an empty Undo step: {finished:?}");
|
||||
assert_eq!(app.text_style.ink, None, "new boxes don't take the ink");
|
||||
|
||||
// Red, then black in the same box: RGB black, not the 100 K back.
|
||||
let mut app = open();
|
||||
recolour(&mut app, [1.0, 0.0, 0.0]);
|
||||
recolour(&mut app, [0.0; 3]);
|
||||
let now = item(&app);
|
||||
assert_eq!((now.ink, now.color), (None, [0.0; 3]));
|
||||
assert_eq!(app.text_style.ink, None);
|
||||
let finished = finish(&mut app);
|
||||
assert!(finished.is_none(), "an empty Undo step: {finished:?}");
|
||||
}
|
||||
@@ -406,7 +406,11 @@ impl PdfCraftApp {
|
||||
/// style at once and stays open. Text already on the page is updated with what has been typed,
|
||||
/// as one undoable change; new text waits until it is finished. Text typed after this styling
|
||||
/// starts from it too.
|
||||
pub(crate) fn restyle_draft(&mut self, index: usize, style: pdfcraft_engine::AddedText) {
|
||||
pub(crate) fn restyle_draft(&mut self, index: usize, mut style: pdfcraft_engine::AddedText) {
|
||||
// A gray or CMYK ink the colour no longer shows goes now (#6440): kept, the draft would
|
||||
// differ from the item the page reads back without it, and a later pick of the ink's
|
||||
// look would bring it back.
|
||||
style.drop_stale_ink();
|
||||
let Some(view) = self.views.get_mut(index) else { return };
|
||||
// Text the standard fonts can't draw stays in the box, unwritten, until it is corrected (#125).
|
||||
let blocked = view.content.blocked().is_some();
|
||||
|
||||
@@ -266,7 +266,8 @@ pub(crate) fn panel(
|
||||
let swatch = colour_button(ui, t, look.color);
|
||||
egui::Popup::menu(&swatch).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if let Some(picked) = crate::comments::swatch_grid(ui, Some(look.color)) {
|
||||
look.color = picked;
|
||||
// Drops a gray or CMYK ink the new colour isn't the look of (#6440).
|
||||
look.set_color(picked);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2047,8 +2047,8 @@ milestone = "M7"
|
||||
status = "shipped"
|
||||
commands = ["edit.text"]
|
||||
tools = ["page_add_text", "content_update"]
|
||||
evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"]
|
||||
notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
|
||||
evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders", "crates/edit/src/tests.rs::gray_and_cmyk_added_text_writes_g_and_k_until_it_is_recoloured", "crates/automation/tests/it/automation.rs::added_text_takes_a_cmyk_or_gray_ink_through_tools", "crates/ui-egui/src/editing/tests.rs::recolouring_print_ink_text_in_its_box_drops_the_ink_without_an_empty_step"]
|
||||
notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. The colour is RGB, or for print files gray or CMYK (#6440: page_add_text / content_update gray or cmyk, painted with g / k so black can be 100 K, kept through save and reopen; choosing another colour later, also from the Format panel, drops the ink for good and paints in RGB, while choosing the colour the ink already shows (black on 100 K) keeps it; content_update color always paints in RGB). Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.find-replace"
|
||||
|
||||
Reference in new issue
Block a user