forked from 1b-agent/opencadstudio-upstream
fix(pdfshxtext): reference grouping, object share, wider glyph set, drawing-order import
Text blocks join side by side or stacked within two heights; oversized shapes join no group; the group share counts selected objects; Latin-1, Latin Extended, Greek, Cyrillic and the diameter sign are compared from the font's own glyphs with look-alikes resolved to the lowest code; a lone circle reads 0; MTEXT direction is by style. PDFIMPORT keeps drawing order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
218150c76a
commit
2eb60f333f
4 files changed
+134
-48
No files matched your search
@@ -288,6 +288,7 @@ impl OpenCADStudio {
|
||||
mtext.height = line.height;
|
||||
mtext.rectangle_width = 0.0;
|
||||
mtext.attachment_point = codec::entities::AttachmentPoint::BottomLeft;
|
||||
mtext.drawing_direction = codec::entities::DrawingDirection::ByStyle;
|
||||
mtext.style = line.font;
|
||||
mtext.common.layer = if settings.current_layer {
|
||||
current_layer.clone()
|
||||
|
||||
@@ -783,6 +783,8 @@ pub const DASH_LINETYPE: &str = "PDF_IMPORT";
|
||||
/// as dashes. `bulges[i]` bends the segment from point i to the next (the
|
||||
/// closing one on a closed stroke; 0 after an open stroke's last point).
|
||||
struct Stroke {
|
||||
/// Drawing position: the objects are created in drawing order.
|
||||
order: usize,
|
||||
layer: String,
|
||||
color: Color,
|
||||
weight: LineWeight,
|
||||
@@ -1205,6 +1207,8 @@ fn join_strokes(strokes: Vec<Stroke>, join: bool) -> Vec<Stroke> {
|
||||
}
|
||||
out.push(chain);
|
||||
}
|
||||
// A chain takes the place of its first stroke.
|
||||
out.sort_by_key(|s| s.order);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1212,6 +1216,7 @@ fn join_strokes(strokes: Vec<Stroke>, join: bool) -> Vec<Stroke> {
|
||||
fn split_at_arcs(stroke: Stroke) -> Vec<Stroke> {
|
||||
let mut out = Vec::new();
|
||||
let piece = |points: Vec<[f64; 2]>, bulges: Vec<f64>| Stroke {
|
||||
order: stroke.order,
|
||||
layer: stroke.layer.clone(),
|
||||
color: stroke.color,
|
||||
weight: stroke.weight,
|
||||
@@ -1364,7 +1369,8 @@ pub fn convert(
|
||||
};
|
||||
let fill_transparency = codec::types::Transparency::from_alpha_value(0x0200_007F);
|
||||
let mut strokes: Vec<Stroke> = Vec::new();
|
||||
let mut curves: Vec<EntityType> = Vec::new();
|
||||
// Circles and splines, by drawing position like the strokes.
|
||||
let mut curves: Vec<(usize, EntityType)> = Vec::new();
|
||||
|
||||
for (pdf_layer, page) in content {
|
||||
let pdf_layer = pdf_layer.as_deref();
|
||||
@@ -1383,6 +1389,7 @@ pub fn convert(
|
||||
LineWeight::ByLayer
|
||||
};
|
||||
for sp in &path.subpaths {
|
||||
let order = strokes.len() + curves.len();
|
||||
if let Some((c, r)) = as_circle(sp) {
|
||||
let w = world(c);
|
||||
let mut circle = EntityType::Circle(Circle::from_center_radius(
|
||||
@@ -1392,7 +1399,7 @@ pub fn convert(
|
||||
let common = circle.common_mut();
|
||||
(common.layer, common.color, common.line_weight) =
|
||||
(layer.clone(), color, weight);
|
||||
curves.push(circle);
|
||||
curves.push((order, circle));
|
||||
} else {
|
||||
// Straight and circular runs become polylines;
|
||||
// each other curve its own one-span SPLINE.
|
||||
@@ -1401,6 +1408,7 @@ pub fn convert(
|
||||
match piece {
|
||||
Piece::Run(points, bulges, closed) => {
|
||||
let stroke = Stroke {
|
||||
order,
|
||||
layer: layer.clone(),
|
||||
color,
|
||||
weight,
|
||||
@@ -1427,7 +1435,7 @@ pub fn convert(
|
||||
let common = spline.common_mut();
|
||||
(common.layer, common.color, common.line_weight) =
|
||||
(layer.clone(), color, weight);
|
||||
curves.push(spline);
|
||||
curves.push((order, spline));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1542,7 +1550,7 @@ pub fn convert(
|
||||
let common = curve.common_mut();
|
||||
(common.layer, common.color, common.line_weight) =
|
||||
(stroke.layer, stroke.color, stroke.weight);
|
||||
result.entities.push(curve);
|
||||
curves.push((stroke.order, curve));
|
||||
None
|
||||
}
|
||||
None => Some(stroke),
|
||||
@@ -1576,7 +1584,7 @@ pub fn convert(
|
||||
);
|
||||
(arc.common.layer, arc.common.color, arc.common.line_weight) =
|
||||
(stroke.layer, stroke.color, stroke.weight);
|
||||
result.entities.push(EntityType::Arc(arc));
|
||||
curves.push((stroke.order, EntityType::Arc(arc)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -1593,9 +1601,11 @@ pub fn convert(
|
||||
pl.common.linetype_scale = dash * scale;
|
||||
result.dashed = true;
|
||||
}
|
||||
result.entities.push(EntityType::LwPolyline(pl));
|
||||
curves.push((stroke.order, EntityType::LwPolyline(pl)));
|
||||
}
|
||||
result.entities.extend(curves);
|
||||
// In drawing order (the sort is stable: a subpath's pieces keep theirs).
|
||||
curves.sort_by_key(|(order, _)| *order);
|
||||
result.entities.extend(curves.into_iter().map(|(_, entity)| entity));
|
||||
result.layers = layer_colors;
|
||||
for image in &result.images {
|
||||
if !result.layers.iter().any(|(n, _)| *n == image.layer) {
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
// 2 text objects created
|
||||
//
|
||||
// The strokes are clustered into characters, the characters into text lines
|
||||
// and the lines into groups (lines of one size stacked together). Each line
|
||||
// is matched against the glyphs of the fonts to compare, in list order: a
|
||||
// group converts with the first font (or, with "Use best matching font", the
|
||||
// best) whose share of recognised characters reaches the threshold. Every
|
||||
// recognised line becomes a bottom-left MTEXT in a style named after the font;
|
||||
// the geometry of its recognised characters is erased.
|
||||
// and the lines into groups (text blocks: lines of one size stacked close
|
||||
// together, or side by side). Each line is matched against the glyphs of the
|
||||
// fonts to compare, in list order: a group converts with the first font (or,
|
||||
// with "Use best matching font", the best) whose share of recognised objects
|
||||
// reaches the threshold. Every recognised line becomes a bottom-left MTEXT in
|
||||
// a style named after the font, a group's lines top down; the geometry of its
|
||||
// recognised characters is erased.
|
||||
|
||||
use codec::Handle;
|
||||
use glam::DVec3;
|
||||
@@ -364,8 +365,21 @@ struct FontShapes {
|
||||
const STEP: f64 = 0.04;
|
||||
/// Largest mean distance (in text heights) of a recognised character.
|
||||
/// The geometry of a font matches its glyphs within a few thousandths; a
|
||||
/// circle read as the 0 of simplex (as the reference reads it) at 0.0121; a
|
||||
/// look-alike glyph of another font (txt's O as a D) from about 0.015.
|
||||
const MATCH_TOLERANCE: f64 = 0.012;
|
||||
const MATCH_TOLERANCE: f64 = 0.013;
|
||||
|
||||
/// The characters compared: printable ASCII, then Latin-1 and Latin
|
||||
/// Extended (°, ±, accented letters), Greek, Cyrillic and the diameter sign
|
||||
/// ⌀ (U+2205, what the reference reads %%c as) — those a font has a glyph of
|
||||
/// its own for.
|
||||
fn compared_chars() -> impl Iterator<Item = char> {
|
||||
(0x21u32..=0x7e)
|
||||
.chain(0xa1..=0x24f)
|
||||
.chain(0x370..=0x4ff)
|
||||
.chain([0x2205])
|
||||
.filter_map(char::from_u32)
|
||||
}
|
||||
|
||||
fn font_shapes(spec: &str) -> Option<FontShapes> {
|
||||
let face = Face::resolve(spec);
|
||||
@@ -378,10 +392,10 @@ fn font_shapes(spec: &str) -> Option<FontShapes> {
|
||||
})
|
||||
.filter(|h| *h > 0.0)?;
|
||||
let mut glyphs = Vec::new();
|
||||
// ponytail: printable ASCII only; symbols (°, ±, Ø, Greek) left as geometry.
|
||||
for code in 0x21u8..=0x7e {
|
||||
let ch = code as char;
|
||||
let Some(g) = face.glyph(ch) else { continue };
|
||||
// A character the font lacks is no candidate (the text fallback would
|
||||
// offer another font's glyph for it).
|
||||
for ch in compared_chars() {
|
||||
let Some(g) = face.own_glyph(ch) else { continue };
|
||||
let strokes: Vec<Vec<P>> = g
|
||||
.strokes
|
||||
.iter()
|
||||
@@ -502,7 +516,7 @@ fn lines_of(strokes: &[Stroke], pieces: Vec<Piece>) -> Vec<Line> {
|
||||
};
|
||||
let mut parent: Vec<usize> = (0..pieces.len()).collect();
|
||||
let tallest = boxes.iter().map(|b| b.h()).fold(0.0f64, f64::max);
|
||||
x_sweep(&boxes, |_| 2.2 * tallest, |a, b| {
|
||||
x_sweep(&boxes, |_| 2.0 * tallest, |a, b| {
|
||||
if !comparable(a, b) {
|
||||
return;
|
||||
}
|
||||
@@ -511,8 +525,9 @@ fn lines_of(strokes: &[Stroke], pieces: Vec<Piece>) -> Vec<Line> {
|
||||
if overlap < 0.3 * ba.h().min(bb.h()) {
|
||||
return;
|
||||
}
|
||||
// Measured: two words 1.8 heights apart are one line, 2.2 apart two.
|
||||
let gap = (ba.min[0].max(bb.min[0]) - ba.max[0].min(bb.max[0])).max(0.0);
|
||||
if gap <= 2.2 * ba.h().max(bb.h()) {
|
||||
if gap <= 2.0 * ba.h().max(bb.h()) {
|
||||
union(&mut parent, a, b);
|
||||
}
|
||||
});
|
||||
@@ -535,7 +550,12 @@ fn lines_of(strokes: &[Stroke], pieces: Vec<Piece>) -> Vec<Line> {
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
for j in 0..pieces.len() {
|
||||
let sj = boxes[j].size();
|
||||
if j == i || si >= 0.35 * sj || !boxes[i].near(&boxes[j], sj) {
|
||||
// A frame or a border around the text is no character to join.
|
||||
if j == i
|
||||
|| si >= 0.35 * sj
|
||||
|| (alone[j] && sj > 20.0 * si)
|
||||
|| !boxes[i].near(&boxes[j], sj)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut d = f64::INFINITY;
|
||||
@@ -601,21 +621,31 @@ fn lines_of(strokes: &[Stroke], pieces: Vec<Piece>) -> Vec<Line> {
|
||||
lines
|
||||
}
|
||||
|
||||
/// Lines of one size stacked close together.
|
||||
/// Lines of one text block (a group). Lines side by side (their bands touch
|
||||
/// or nearly: rotated text breaks into such lines at its spaces) join when
|
||||
/// they are close; lines stacked above each other when they are of one size
|
||||
/// and no more than twice the smaller line's height apart. Measured: lines
|
||||
/// two heights apart are one block, 2.5 apart two; 4.6 high under 5 one
|
||||
/// block, 4 high under 5 (two heights apart) two.
|
||||
fn groups_of(lines: &[Line]) -> Vec<Vec<usize>> {
|
||||
let mut parent: Vec<usize> = (0..lines.len()).collect();
|
||||
for a in 0..lines.len() {
|
||||
for b in a + 1..lines.len() {
|
||||
let (la, lb) = (&lines[a], &lines[b]);
|
||||
let h = la.height.max(lb.height);
|
||||
if h <= 0.0 || la.height.min(lb.height) < 0.85 * h {
|
||||
let (small, large) = (la.height.min(lb.height), la.height.max(lb.height));
|
||||
if small <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let x_gap = (la.bbox.min[0].max(lb.bbox.min[0]) - la.bbox.max[0].min(lb.bbox.max[0]))
|
||||
.max(0.0);
|
||||
let y_gap = (la.bbox.min[1].max(lb.bbox.min[1]) - la.bbox.max[1].min(lb.bbox.max[1]))
|
||||
.max(0.0);
|
||||
if x_gap <= 2.0 * h && y_gap <= 3.2 * h {
|
||||
let joins = if y_gap <= 0.5 * small {
|
||||
small >= 0.35 * large && x_gap <= 2.0 * small
|
||||
} else {
|
||||
small >= 0.85 * large && x_gap <= 2.0 * large && y_gap <= 2.0 * small
|
||||
};
|
||||
if joins {
|
||||
union(&mut parent, a, b);
|
||||
}
|
||||
}
|
||||
@@ -625,10 +655,10 @@ fn groups_of(lines: &[Line]) -> Vec<Vec<usize>> {
|
||||
groups.entry(find(&mut parent, i)).or_default().push(i);
|
||||
}
|
||||
let mut out: Vec<Vec<usize>> = groups.into_values().collect();
|
||||
// In the order the geometry was drawn.
|
||||
// In the order the geometry was drawn; a block's lines top down.
|
||||
out.sort_by_key(|g| g.iter().map(|l| lines[*l].first).min().unwrap_or(0));
|
||||
for g in &mut out {
|
||||
g.sort_by_key(|l| lines[*l].first);
|
||||
g.sort_by(|a, b| lines[*b].bbox.max[1].total_cmp(&lines[*a].bbox.max[1]));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -638,7 +668,8 @@ fn median(mut v: Vec<f64>) -> Option<f64> {
|
||||
return None;
|
||||
}
|
||||
v.sort_by(|a, b| a.total_cmp(b));
|
||||
Some(v[v.len() / 2])
|
||||
// The lower middle: round letters overshoot the cap height.
|
||||
Some(v[(v.len() - 1) / 2])
|
||||
}
|
||||
|
||||
/// Height the reference gives a line that has nothing with a height in it
|
||||
@@ -667,7 +698,9 @@ fn match_line(line: &Line, font: &FontShapes) -> LineMatch {
|
||||
.collect();
|
||||
scored.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let best = scored.first().map_or(f64::INFINITY, |b| b.1);
|
||||
scored.retain(|(_, d)| *d <= (1.3 * best + 0.01).min(0.12));
|
||||
// Wide enough to keep the 0 of a lone circle among the rounder
|
||||
// readings (° and o fit it closer when they are compared).
|
||||
scored.retain(|(_, d)| *d <= (1.3 * best + 0.015).min(0.12));
|
||||
first.push(scored);
|
||||
}
|
||||
// The line's scale: the one most characters agree with through one of
|
||||
@@ -690,23 +723,30 @@ fn match_line(line: &Line, font: &FontShapes) -> LineMatch {
|
||||
.filter_map(|(ci, hs)| {
|
||||
hs.iter()
|
||||
.filter(|(_, h)| (h / s - 1.0).abs() <= 0.06)
|
||||
.min_by(|a, b| (a.1 / s - 1.0).abs().total_cmp(&(b.1 / s - 1.0).abs()))
|
||||
// Readings of one height (0 / O) go to the lower code.
|
||||
.min_by(|a, b| {
|
||||
(a.1 / s - 1.0)
|
||||
.abs()
|
||||
.total_cmp(&(b.1 / s - 1.0).abs())
|
||||
.then(font.glyphs[a.0].ch.cmp(&font.glyphs[b.0].ch))
|
||||
})
|
||||
.map(|(gi, _)| (ci, *gi))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// On a tie the taller reading wins: a lone round shape is an O at cap
|
||||
// height rather than an o.
|
||||
// On a tie the reading at cap height wins: a lone round shape is an O
|
||||
// (or a 0) rather than an o, a row of bars I rather than |.
|
||||
let mut support: Vec<(usize, usize)> = Vec::new();
|
||||
let mut support_scale = f64::INFINITY;
|
||||
let mut support_cap = f64::INFINITY;
|
||||
for hs in &hyps {
|
||||
for (_, h) in hs {
|
||||
for (gi, h) in hs {
|
||||
let votes = agree(*h);
|
||||
let cap = (font.glyphs[*gi].bbox.h() - 1.0).abs();
|
||||
if votes.len() > support.len()
|
||||
|| (votes.len() == support.len() && *h < support_scale * 0.94)
|
||||
|| (votes.len() == support.len() && cap < support_cap - 0.05)
|
||||
{
|
||||
support = votes;
|
||||
support_scale = *h;
|
||||
support_cap = cap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -766,7 +806,7 @@ fn match_line(line: &Line, font: &FontShapes) -> LineMatch {
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let samples = sample(&c.geometry, step);
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
let mut scores: Vec<(usize, f64)> = Vec::new();
|
||||
for (gi, g) in font.glyphs.iter().enumerate() {
|
||||
let gh = g.bbox.h() * height;
|
||||
let gw = g.bbox.w() * height * width_factor;
|
||||
@@ -791,11 +831,16 @@ fn match_line(line: &Line, font: &FontShapes) -> LineMatch {
|
||||
})
|
||||
.collect();
|
||||
let d = chamfer(&samples, &c.geometry, &sample(&placed, step), &placed) / height;
|
||||
if best.is_none_or(|(_, bd)| d < bd) {
|
||||
best = Some((gi, d));
|
||||
}
|
||||
scores.push((gi, d));
|
||||
}
|
||||
best.filter(|(_, d)| *d <= MATCH_TOLERANCE).map(|(gi, _)| gi)
|
||||
// Look-alikes (H and Greek Eta, y and a Cyrillic u drawn from
|
||||
// another font) go to the lowest code, Latin before the rest.
|
||||
let least = scores.iter().map(|(_, d)| *d).fold(f64::INFINITY, f64::min);
|
||||
scores
|
||||
.iter()
|
||||
.find(|(_, d)| *d <= least + 0.003)
|
||||
.filter(|(_, d)| *d <= MATCH_TOLERANCE)
|
||||
.map(|(gi, _)| *gi)
|
||||
})
|
||||
.collect();
|
||||
LineMatch {
|
||||
@@ -867,7 +912,12 @@ pub fn recognize(
|
||||
settings: &ShxTextSettings,
|
||||
resolve: impl Fn(&str) -> String,
|
||||
) -> Recognition {
|
||||
let pieces = pieces_of(strokes);
|
||||
let mut pieces = pieces_of(strokes);
|
||||
// A shape many times the size of the selection's typical piece (a
|
||||
// frame, a border line) is no character: it joins no group.
|
||||
if let Some(typical) = median(pieces.iter().map(|p| p.bbox.size()).collect()) {
|
||||
pieces.retain(|p| p.bbox.size() <= 10.0 * typical);
|
||||
}
|
||||
let lines = lines_of(strokes, pieces);
|
||||
let groups = groups_of(&lines);
|
||||
let fonts: Vec<(String, FontShapes)> = settings
|
||||
@@ -881,15 +931,29 @@ pub fn recognize(
|
||||
lines: Vec::new(),
|
||||
};
|
||||
for group in groups {
|
||||
let total: usize = group.iter().map(|l| lines[*l].chars.len()).sum();
|
||||
let chars: usize = group.iter().map(|l| lines[*l].chars.len()).sum();
|
||||
// The share is of the selected objects a font recognises (measured:
|
||||
// 9 of 14 curves read 64%, 12 of 15 read 80%), not of characters.
|
||||
let objects = |recognised: &dyn Fn(usize, usize) -> bool| -> usize {
|
||||
let mut handles: Vec<Handle> = group
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(k, l)| {
|
||||
lines[*l].chars.iter().enumerate().map(move |(c, ch)| (k, c, ch))
|
||||
})
|
||||
.filter(|(k, c, _)| recognised(*k, *c))
|
||||
.flat_map(|(_, _, ch)| ch.strokes.iter().map(|s| strokes[*s].handle))
|
||||
.collect();
|
||||
handles.sort();
|
||||
handles.dedup();
|
||||
handles.len()
|
||||
};
|
||||
let total = objects(&|_, _| true);
|
||||
let mut best: Option<(usize, u32, Vec<LineMatch>)> = None;
|
||||
let mut chosen: Option<(usize, u32, Vec<LineMatch>)> = None;
|
||||
for (fi, (_, shapes)) in fonts.iter().enumerate() {
|
||||
let matches: Vec<LineMatch> = group.iter().map(|l| match_line(&lines[*l], shapes)).collect();
|
||||
let recognised: usize = matches
|
||||
.iter()
|
||||
.map(|m| m.glyphs.iter().filter(|g| g.is_some()).count())
|
||||
.sum();
|
||||
let recognised = objects(&|k, c| matches[k].glyphs[c].is_some());
|
||||
let pct = percent(recognised, total);
|
||||
if pct >= u32::from(settings.threshold) && !settings.best_font {
|
||||
chosen = Some((fi, pct, matches));
|
||||
@@ -901,7 +965,7 @@ pub fn recognize(
|
||||
}
|
||||
// A lone shape no font recognises (a frame, a boundary) is not text:
|
||||
// it is no group.
|
||||
if chosen.is_none() && total == 1 && best.as_ref().is_none_or(|(_, pct, _)| *pct == 0) {
|
||||
if chosen.is_none() && chars == 1 && best.as_ref().is_none_or(|(_, pct, _)| *pct == 0) {
|
||||
continue;
|
||||
}
|
||||
if chosen.is_none()
|
||||
|
||||
@@ -147,6 +147,17 @@ impl Face {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ch`'s glyph from this face itself, without the TrueType fallback
|
||||
/// [`glyph`](Self::glyph) reaches for when the face lacks it.
|
||||
pub fn own_glyph(&self, ch: char) -> Option<GlyphRef<'_>> {
|
||||
match self {
|
||||
Face::Lff(f) => f.glyph(ch).map(GlyphRef::Borrowed),
|
||||
Face::Ttf { family, .. } => ttf_glyph::glyph(family, ch).map(GlyphRef::Owned),
|
||||
Face::Shx { path, .. } => shx::font_glyph(path, ch as u16).map(GlyphRef::Owned),
|
||||
Face::WithBig { primary, .. } => primary.own_glyph(ch),
|
||||
}
|
||||
}
|
||||
|
||||
/// The TrueType face [`glyph`](Self::glyph) takes `ch`'s outline from, or
|
||||
/// `None` when that glyph is a stroke (LFF / SHX / big-font) glyph. Follows
|
||||
/// the exact dispatch of `glyph` so an exporter that embeds the face draws
|
||||
|
||||
Reference in new issue
Block a user