diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 863ea989..100fede0 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -730,6 +730,85 @@ fn same_type(n: &Node, lines: &[(String, Point)]) -> Option { .min_by(f64::total_cmp) } +/// The page draws a line in pieces where its kerning or styles change ("swe", "ep"), and the file +/// has it as one line of one text object: the pieces that on one baseline add up to a line of +/// `lines` become one text (each keeping its own style, the line's spaces between them). +fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { + let text_of = |n: &Arc| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }; + let texts: Vec = parts.iter().enumerate().filter(|(_, n)| matches!(n.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); + let mut taken: Vec = vec![]; + // The first piece of each line to join, the others, and the spaces to put after each piece. + let mut joins: Vec<(usize, Vec, Vec)> = vec![]; + for &i in &texts { + if taken.contains(&i) { + continue; + } + let Some(line) = parts.get(i).and_then(|n| baseline(n)) else { continue }; + let group: Vec = texts + .iter() + .copied() + .filter(|j| !taken.contains(j) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE)) + .collect(); + taken.extend(&group); + let pieces: Vec = group.iter().filter_map(|j| parts.get(*j).and_then(text_of)).collect(); + let places: Vec<(f64, f64)> = group + .iter() + .filter_map(|j| parts.get(*j)) + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((t.xf.as_coeffs()[4], t.xf.as_coeffs()[5])) } else { None }) + .collect(); + let distinct = places.iter().enumerate().all(|(a, p)| places.iter().skip(a + 1).all(|q| (p.0 - q.0).hypot(p.1 - q.1) > 0.01)); + let joined: String = pieces.iter().map(|p| letters(p)).collect(); + if group.len() < 2 || !distinct || joined.is_empty() { + continue; + } + let Some((whole, _)) = lines.iter().find(|(text, _)| letters(text) == joined) else { continue }; + // Where the line has spaces after a piece's last character that the page doesn't draw. + let line_chars: Vec = whole.chars().collect(); + let mut at = 0; + let mut gaps = vec![]; + for (k, piece) in pieces.iter().enumerate() { + for _ in piece.chars().filter(|c| !c.is_whitespace()) { + while line_chars.get(at).is_some_and(|c| c.is_whitespace()) { + at += 1; + } + at += 1; + } + let space: String = line_chars.iter().skip(at).take_while(|c| c.is_whitespace()).collect(); + let next = pieces.get(k + 1); + let drawn = piece.ends_with(char::is_whitespace) || next.is_none_or(|n| n.starts_with(char::is_whitespace)); + gaps.push(if drawn { String::new() } else { space }); + } + if at <= line_chars.len() { + joins.push((group[0], group[1..].to_vec(), gaps)); + } + } + for (first, rest, gaps) in joins.iter().rev() { + let mut runs: Vec> = [first] + .into_iter() + .chain(rest) + .filter_map(|j| parts.get(*j)) + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.runs.clone()) } else { None }) + .collect(); + for (r, gap) in runs.iter_mut().zip(gaps) { + if let Some(last) = r.last_mut() { + last.text.push_str(gap); + } + } + if let Some(node) = parts.get_mut(*first).map(Arc::make_mut) + && let NodeKind::Text(t) = &mut node.kind + { + t.runs = runs.into_iter().flatten().collect(); + } + let mut gone: Vec = rest.clone(); + gone.sort_unstable_by(|a, b| b.cmp(a)); + for j in gone { + if j < parts.len() { + parts.remove(j); + } + } + } +} + /// The box round `parts`. fn extent(parts: &[Arc]) -> Option { parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) @@ -787,6 +866,11 @@ fn plan( None => spare.push(object.clone()), } } + for (k, c) in content.iter_mut().enumerate() { + if let (Content::Page(parts), Some(Some(l))) = (c, lines.get(k)) { + join_pieces(parts, l); + } + } // Type of the text document where the page has none: hidden type, and shown type off the page. for (k, c) in content.iter_mut().enumerate() { if matches!(c, Content::Empty) diff --git a/crates/eps/src/import/tests_fontnames.rs b/crates/eps/src/import/tests_fontnames.rs index 8aa4c0ed..f0dd9c6c 100644 --- a/crates/eps/src/import/tests_fontnames.rs +++ b/crates/eps/src/import/tests_fontnames.rs @@ -46,3 +46,13 @@ fn reencoded_copies_of_a_font_name_the_font() { assert_eq!(crate::family_style("Odd*Name").0, "Odd*Name"); assert_eq!(crate::family_style("*1").0, "*1"); } + +#[test] +fn abbreviated_styles_in_postscript_names_are_spelled_out() { + // A style word PostScript names shorten is the style the installed font names in full. + assert_eq!(crate::family_style("AkzidenzGroteskPro-Md"), ("Akzidenz Grotesk Pro".to_string(), "Medium".to_string())); + assert_eq!(crate::family_style("AkzidenzGroteskPro-XBdCnIt").1, "Extra Bold Condensed Italic"); + assert_eq!(crate::family_style("AkzidenzGroteskPro-LightCn").1, "Light Condensed"); + // Whole words stay as they were. + assert_eq!(crate::family_style("Helvetica-BoldOblique").1, "Bold Oblique"); +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 3eb6f571..6fddba75 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -417,9 +417,12 @@ fn a_line_the_page_draws_in_pieces_goes_to_its_text_object_whole() { let art = native_with_text(&layer("Art", true, &text_object(0)), &doc); let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show"; let r = import(&eps(page, &art)).unwrap(); + // One text, with the line's space between the pieces. let group = &r.document.layers[0].children().unwrap()[0]; - assert_eq!(kinds(group), ["text", "text"], "{:?}", r.warnings); - assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(kinds(group), ["text"], "{:?}", r.warnings); + let texts: Vec = + group.children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect(); + assert_eq!(texts, ["Hi there"]); } #[test] diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 42de4d0b..702cc9f1 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -18,6 +18,27 @@ use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err}; /// The font a program uses before it sets one. const DEFAULT_FONT: &str = "Helvetica"; +/// A word of a style as PostScript names abbreviate it (`AkzidenzGroteskPro-BoldCnIt`), in full. +fn style_word(w: &str) -> String { + match w { + "Lt" => "Light", + "Md" | "Med" => "Medium", + "Bd" => "Bold", + "XBd" => "Extra Bold", + "Blk" => "Black", + "Hv" => "Heavy", + "Th" => "Thin", + "Rg" => "Regular", + "Sb" | "Smbd" => "Semibold", + "Cn" | "Cond" => "Condensed", + "Ext" | "Ex" => "Extended", + "It" => "Italic", + "Obl" => "Oblique", + w => w, + } + .to_string() +} + /// A font's family and style from its PostScript name: `Helvetica-BoldOblique` → (`Helvetica`, /// `Bold Oblique`), `TimesNewRomanPS-BoldMT` → (`Times New Roman`, `Bold`), without a subset /// prefix (`ABCDEF+`) or the `*1` of a re-encoded copy (`ABCDEF+RollerBabyBV*1`). @@ -47,7 +68,7 @@ pub fn family_style(name: &str) -> (String, String) { }; let style = match trim(style).as_str() { "" | "Roman" | "Regular" | "Book" | "Normal" => "Regular".to_string(), - s => s.to_string(), + s => s.split(' ').map(style_word).collect::>().join(" "), }; (trim(family), style) }