Attach Underlay ribbon menu, DWF/DGN attach dialog, DGN true colours

- Insert > Reference: the PDF Attach button becomes an Attach Underlay menu
  (PDF / DWF / DGN); its face runs the last one chosen.
- DWFATTACH, DGNATTACH and ATTACH on a .dwf/.dwfx/.dgn open the Attach
  dialog for that kind: sheets or models with thumbnails (one chosen),
  placement, path type, file details with the sheet / model size, and for
  DGN the conversion units (sub units offer 1 / sub units per master as the
  scale).
- DGN V8 true colours come from the ExtendedColors record packed in the
  non-model attributes stream; the colour word's high part numbers them.
- A DGN model's extent includes each text's box (its length along the
  baseline and 1.5 x its height), as the reference sizes a model.
- DWFx text uses the family named in its embedded (deobfuscated) font.
- DWF contrast pivots at 0.73 once colours are turned over for a dark
  background.
- Hairline strokes are drawn solid one pixel wide, and underlay rasters are
  made at or below their size on screen so those lines stay unbroken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-09-28 17:08:06 +03:00
1 parent fecbcd8138
commit 5b6cbff5b2
33 files changed
+745 -59

No files matched your search

+2 -2
View File
@@ -2753,7 +2753,7 @@ impl Scene {
let Some(wpp) = self.world_per_pixel() else {
return;
};
let step = (f64::from(wpp).log2() * 4.0).floor() as i32;
let step = (f64::from(wpp).log2() * 4.0).ceil() as i32;
let current = self.underlay_world_per_pixel.map(|w| (w.log2() * 4.0).round() as i32);
if current == Some(step) {
self.underlay_scale_pending = None;
@@ -2783,7 +2783,7 @@ impl Scene {
let Some(wpp) = self.world_per_pixel() else {
return false;
};
let step = (f64::from(wpp).log2() * 4.0).floor() as i32;
let step = (f64::from(wpp).log2() * 4.0).ceil() as i32;
self.underlay_world_per_pixel.map(|w| (w.log2() * 4.0).round() as i32) != Some(step)
}
+9 -1
View File
@@ -207,7 +207,8 @@ impl ImageModel {
let page = crate::entities::underlay::page_of(def);
let rect = crate::entities::underlay::definition_rect(def)?;
// The page's longest side on screen, pixels (the view scale is taken
// a step finer, so the raster is never enlarged on screen).
// a step coarser, so the raster is never reduced on screen and its
// one-pixel lines stay unbroken).
let screen_side = world_per_pixel
.filter(|wpp| *wpp > 0.0)
.map(|wpp| {
@@ -258,8 +259,15 @@ impl ImageModel {
&raster,
PageAdjust {
contrast: u.contrast.min(100),
// Measured: PDF 0.5; DWF 0.243, or 0.73 once its colours are
// turned over for a dark background; DGN 0.65.
contrast_pivot: match def.underlay_type {
UnderlayType::Pdf => 500,
UnderlayType::Dwf
if u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) && bg_lum < 0.5 =>
{
730
}
UnderlayType::Dwf => 243,
UnderlayType::Dgn => 650,
},
+43 -5
View File
@@ -212,19 +212,57 @@ struct Styles {
levels: HashMap<u32, u32>,
/// Levels the underlay turns off: their elements are left out.
hidden: std::collections::HashSet<u32>,
/// True colours, in the order colour words number them (1 first).
extended: Vec<[u8; 3]>,
}
/// The true colours of the file: a zlib-packed UTF-16 record in the
/// non-model attributes stream reading
/// `<ExtendedColors><Entry Color="(r,g,b)"/>…</ExtendedColors>`.
fn extended_colors(cfb: &Cfb) -> Vec<[u8; 3]> {
let Some(attrs) = cfb.streams().iter().find(|(p, _)| p == "Dgn^NmA/$1").map(|(_, e)| inflate(&cfb.stream(e))) else {
return Vec::new();
};
for at in 0..attrs.len().saturating_sub(2) {
// A zlib header: deflate, and a check value divisible by 31.
let (a, b) = (attrs[at], attrs[at + 1]);
if a & 0x0f != 8 || ((a as u16) << 8 | b as u16) % 31 != 0 {
continue;
}
let mut out = Vec::new();
if flate2::read::ZlibDecoder::new(&attrs[at..]).read_to_end(&mut out).is_err() || out.len() < 2 {
continue;
}
let units: Vec<u16> = out.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
let text = String::from_utf16_lossy(&units);
if !text.contains("<ExtendedColors") {
continue;
}
return text
.split("Color=\"(")
.skip(1)
.filter_map(|rest| {
let v: Vec<u8> = rest.split(')').next()?.split(',').filter_map(|n| n.trim().parse().ok()).collect();
(v.len() == 3).then(|| [v[0], v[1], v[2]])
})
.collect();
}
Vec::new()
}
impl Styles {
fn color(&self, e: &[u8]) -> [u8; 3] {
// The colour word: an index into the colour table, 0xFFFFFFFF for
// the level's colour, or a true colour — n × 256 plus the index of
// its nearest table colour.
// ponytail: a true colour draws as that nearest index; the RGB it
// stands for is not read.
// the level's colour, or true colour n (from 1) × 256 plus the index
// of its nearest table colour, drawn in its own RGB when the file
// lists it.
let explicit = u32_at(e, 52).unwrap_or(u32::MAX);
let index = if explicit == u32::MAX {
self.levels.get(&u32_at(e, 12).unwrap_or(0)).copied().unwrap_or(0)
} else {
if let Some(rgb) = (explicit >> 8).checked_sub(1).and_then(|n| self.extended.get(n as usize)) {
return *rgb;
}
explicit & 0xff
};
self.colors.get(index as usize).copied().unwrap_or([255, 255, 255])
@@ -580,7 +618,7 @@ pub fn model(bytes: &[u8], name: &str, hidden: &[String]) -> Option<Sheet> {
.filter(|l| hidden.iter().any(|h| h.eq_ignore_ascii_case(&l.1)))
.map(|l| l.0)
.collect();
let styles = Styles { colors, levels, hidden };
let styles = Styles { colors, levels, hidden, extended: extended_colors(&cfb) };
// Shared cell definitions: a type-34 element and its components.
let mut defs = HashMap::new();
let mut i = 0;
+66 -6
View File
@@ -164,12 +164,69 @@ pub fn sheet(bytes: &[u8], name: &str) -> Option<Sheet> {
w2d::read(data)?
} else {
let w2x = entry.w2x.as_ref().and_then(|p| files.get(p));
xps::read(data, w2x.map(|v| v.as_slice()), entry)?
// An embedded font (FontUri, relative to the page) names its family.
let page_dir = entry.graphics.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default();
let family = |uri: &str| -> Option<String> {
let uri = uri.trim_start_matches("./");
let key = if let Some(abs) = uri.strip_prefix('/') { abs.to_string() } else { format!("{page_dir}/{uri}") };
let data = files.get(&key)?;
let name = key.rsplit('/').next()?;
font_family(&deobfuscate(data, name)?)
};
xps::read(data, w2x.map(|v| v.as_slice()), entry, &family)?
};
let rect = view.or_else(|| paths_bounds(&paths))?;
Some(Sheet { rect, paths, texts, sub_per_master: 1.0 })
}
/// An XPS obfuscated font (.odttf): its first 32 bytes are XORed with the
/// GUID of its file name, byte order reversed.
fn deobfuscate(data: &[u8], file_name: &str) -> Option<Vec<u8>> {
let hex: String = file_name.split('.').next()?.chars().filter(|c| c.is_ascii_hexdigit()).collect();
if hex.len() != 32 || data.len() < 32 {
return None;
}
let bytes: Vec<u8> = (0..16).filter_map(|i| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()).collect();
let key: Vec<u8> = bytes.into_iter().rev().collect();
let mut out = data.to_vec();
for i in 0..32 {
out[i] ^= key[i % 16];
}
Some(out)
}
/// The family name (name id 1) of a TrueType font.
fn font_family(font: &[u8]) -> Option<String> {
let u16be = |at: usize| Some(u16::from_be_bytes(font.get(at..at + 2)?.try_into().ok()?));
let u32be = |at: usize| Some(u32::from_be_bytes(font.get(at..at + 4)?.try_into().ok()?));
let tables = u16be(4)? as usize;
let name = (0..tables).find_map(|t| {
let at = 12 + t * 16;
(font.get(at..at + 4)? == b"name").then(|| u32be(at + 8)).flatten()
})? as usize;
let count = u16be(name + 2)? as usize;
let strings = name + u16be(name + 4)? as usize;
let mut fallback = None;
for r in 0..count {
let at = name + 6 + r * 12;
let (platform, name_id) = (u16be(at)?, u16be(at + 6)?);
if name_id != 1 {
continue;
}
let (len, off) = (u16be(at + 8)? as usize, u16be(at + 10)? as usize);
let raw = font.get(strings + off..strings + off + len)?;
match platform {
// Windows: UTF-16 big endian.
3 | 0 => {
let units: Vec<u16> = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
return Some(String::from_utf16_lossy(&units));
}
_ => fallback = Some(String::from_utf8_lossy(raw).to_string()),
}
}
fallback
}
/// The default colour map of a W2D stream: ten system colours, a 6×6×6
/// colour cube from index 10 (red 36 steps, green 6, blue 1), a grey ramp
/// at 226-245 and ten more system colours (measured against plotted files:
@@ -617,7 +674,12 @@ mod xps {
}
/// The fixed page's paths and text in model units, and the plotted view.
pub fn read(page: &[u8], w2x: Option<&[u8]>, entry: &SheetEntry) -> Option<(Vec<Path>, Option<[f64; 4]>, Vec<Text>)> {
pub fn read(
page: &[u8],
w2x: Option<&[u8]>,
entry: &SheetEntry,
family: &dyn Fn(&str) -> Option<String>,
) -> Option<(Vec<Path>, Option<[f64; 4]>, Vec<Text>)> {
let mut units = Units::identity();
let mut view_log = None;
if let Some(w2x) = w2x.and_then(|b| std::str::from_utf8(b).ok()) {
@@ -660,9 +722,7 @@ mod xps {
};
if node.tag_name().name() == "Glyphs" {
// Text: its baseline origin and em size in page units (y
// down), drawn in the plotted family.
// ponytail: the embedded font is obfuscated and not read;
// DWFx text is drawn in Arial.
// down), drawn in the embedded font's family.
let num = |name: &str| attr(node, name).and_then(|v| v.parse::<f64>().ok());
let (Some(em), Some(text)) = (num("FontRenderingEmSize"), attr(node, "UnicodeString")) else { return };
let o = [num("OriginX").unwrap_or(0.0), num("OriginY").unwrap_or(0.0)];
@@ -677,7 +737,7 @@ mod xps {
width_factor: if len(up) > 0.0 { len(along) / len(up) } else { 1.0 },
rotation: (along[1] - a[1]).atan2(along[0] - a[0]),
color: attr(node, "Fill").and_then(parse_color).unwrap_or([0, 0, 0]),
font: "Arial".to_string(),
font: attr(node, "FontUri").and_then(family).unwrap_or_else(|| "Arial".to_string()),
});
}
return;
+28 -9
View File
@@ -93,15 +93,31 @@ pub fn sheet_without(kind: UnderlayType, path: &str, item: &str, hidden: &[Strin
UnderlayType::Dgn => dgn8::model(&bytes, item, hidden).unwrap_or_default(),
UnderlayType::Pdf => return None,
};
outline_texts(&mut sheet);
// A DGN model's extent is its geometry, text included (a DWF sheet
// keeps its plotted view).
// A DGN model's extent includes its text: each text's box runs its
// length along the baseline and 1.5 × its height above it (as the
// reference measures a model). A DWF sheet keeps its plotted view.
if kind == UnderlayType::Dgn {
if let Some(b) = model::paths_bounds(&sheet.paths) {
let r = sheet.rect;
sheet.rect = [r[0].min(b[0]), r[1].min(b[1]), r[2].max(b[2]), r[3].max(b[3])];
let mut r = sheet.rect;
for t in &sheet.texts {
let (strokes, _) = crate::scene::text::lff::tessellate_text_ex(
[0.0, 0.0],
t.height as f32,
0.0,
t.width_factor as f32,
0.0,
&t.font,
&t.text,
);
let length = strokes.iter().flatten().map(|p| p[0] as f64).fold(0.0, f64::max);
let (c, s) = (t.rotation.cos(), t.rotation.sin());
for [x, y] in [[0.0, 0.0], [length, 0.0], [length, 1.5 * t.height], [0.0, 1.5 * t.height]] {
let p = [t.origin[0] + x * c - y * s, t.origin[1] + x * s + y * c];
r = [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])];
}
}
sheet.rect = r;
}
outline_texts(&mut sheet);
if !hidden.is_empty() {
sheet.rect = sheet_without(kind, path, item, &[])?.rect;
}
@@ -206,10 +222,13 @@ fn rasterize(sheet: &Sheet, side: f64) -> Option<PdfPage> {
if let Some((c, width)) = path.stroke {
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(c[0], c[1], c[2], 255);
paint.anti_alias = true;
// A negative width is in pixels (DGN line weights).
let px = if width < 0.0 { (-width) as f32 } else { ((width * scale) as f32).max(1.0) };
// Hairlines are drawn solid, one pixel wide, as the reference
// draws them; anti-aliasing would spread them to half intensity.
paint.anti_alias = px > 1.5;
let stroke = tiny_skia::Stroke {
// A negative width is in pixels (DGN line weights).
width: if width < 0.0 { (-width) as f32 } else { ((width * scale) as f32).max(1.0) },
width: px,
line_cap: tiny_skia::LineCap::Round,
line_join: tiny_skia::LineJoin::Round,
..Default::default()