Files
designcraft/crates/engine/tests/support/optional_content.rs
T
9da88149be Fix Links panel stalls on network paths (#1444)
* Fix Links panel stalls on network paths

* Fix Links review findings

* Fix Links worker gating and wasm build

A link check blocked on another document's unreachable share no longer
holds back the document now shown: after 200 ms a fresh worker takes
over (at most four alive at once) and the stale result is dropped.

relinked_asset used the native-only fingerprint_from, breaking the
wasm build; it now uses fingerprint_of. The EPS relink and IDML link
reach tests build Windows paths correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix Links worker repaint and graphic read retry

Switching documents before the 200 ms takeover now schedules a repaint
for it: a blocked worker never asks for one, so without other input the
panel kept waiting. A failed graphic read is no longer cached as
missing, so the next check reads again. The legacy EPS fixture clears
its source fingerprint, as documents from before fingerprints have
none, so the test no longer depends on running within two seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep touched links OK and poll Links within seconds

Only a new size now marks a graphic Modified from its fingerprint. A
file with the same size but a new time (saved again, copied, restored
or checked out) is compared byte for byte, once per new time, and stays
OK when unchanged.

External changes are polled every two seconds instead of every five
minutes. The next check waits at least as long as the last one took
(up to five minutes), so a slow share stays idle half the time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Wake Links when a worker slot frees; translate check strings

A worker thread asks for a repaint when it ends, so a document waiting
at the four-worker limit gets a worker as soon as a superseded one
returns from its blocked call, without other input.

Rows from a check keep a weak reference to their document. The key
names it by address, which a later document can't reuse while the
allocation is kept.

The Links panel's new strings ("Checking", "Checking links…", "Link
status is unavailable.") use the UI's ellipsis and are translated in
every interface language. The comments on Session::text_link_checks and
on the byte comparison in links::status describe what they do now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Links: rows from the document, statuses from a check that can stop

The panel lists links from the document on the UI thread (no file is
looked at) and fills in the statuses a worker check last found, kept
per document. An edit shows at once, and a check of an earlier edit
still supplies statuses instead of being discarded.

- A check of another document is told to stop and ends before its next
  file; only one blocked in a file system call is replaced, so blocked
  workers no longer hold their slots for a whole document.
- Checks are timed on the worker, and what they remember passes to the
  worker that replaces them.
- A fingerprint is kept only for a file whose time was settled when it
  was read, as for linked text. A file found unchanged by its bytes
  gets its current size and time stored with the document
  (remember_link_files), without marking a saved document as changed.
- Asset::set_link keeps link and fingerprint together; copies made by
  Package and Copy Links To carry none.
- The Session holds one LinkListChecks. Update Link lists files only
  when a linked story was edited in the layout.
- The unused "Checking links..." caption is removed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-10 17:39:39 +01:00

176 lines
7.8 KiB
Rust

use std::sync::Arc;
use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef, build::NewDocument};
use designcraft_geom::{Affine, Rect, shapes};
use hayro_syntax::{
Pdf,
object::{Array, Dict, Name, ObjectIdentifier},
};
pub fn fixture(config: &str, second_name: &str) -> Vec<u8> {
layered_fixture(Some(config), second_name)
}
pub fn orphaned_fixture() -> Vec<u8> {
layered_fixture(None, "Hidden")
}
fn layered_fixture(config: Option<&str>, second_name: &str) -> Vec<u8> {
let properties = config.map(|config| format!("/OCProperties<</OCGs[5 0 R 6 3 R]/D<<{config}>>>>")).unwrap_or_default();
properties_fixture(&properties, second_name)
}
pub fn properties_fixture(properties: &str, second_name: &str) -> Vec<u8> {
grouped_fixture(properties, Some(second_name))
}
pub fn nameless_fixture(config: &str) -> Vec<u8> {
grouped_fixture(&format!("/OCProperties<</OCGs[5 0 R 6 3 R]/D<<{config}>>>>"), None)
}
fn grouped_fixture(properties: &str, second_name: Option<&str>) -> Vec<u8> {
let content = "/OC /a BDC 1 0 0 0 k 0 0 50 100 re f EMC /OC /b BDC 0 1 0 0 k 50 0 50 100 re f EMC";
let name = second_name.map(|name| format!("/Name({name})")).unwrap_or_default();
let objects = [
format!("<</Type/Catalog/Pages 2 0 R{properties}>>"),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 3 R>>>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</Type/OCG/Name(Shown)/Intent[/View/Design]/Usage<</Print<</PrintState/ON>>>>/Custom(keep \\(this\\) >>)>>".into(),
format!("<</Type/OCG{name}/Intent[/View/Design]/Usage<</Print<</PrintState/ON>>>>/Custom<00ff80>>>"),
];
assemble(&objects, &[2, 0, 0, 0, 0, 3])
}
pub fn unlayered_fixture() -> Vec<u8> {
let content = "1 0 0 0 k 0 0 50 100 re f";
assemble(
&[
"<</Type/Catalog/Pages 2 0 R>>".into(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<<>>/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
],
&[2, 0, 0, 0],
)
}
pub fn stream_fixture(config: &str) -> Vec<u8> {
let mut bytes = fixture(config, "Hidden");
let tail = std::str::from_utf8(&bytes).unwrap();
let previous: usize = tail.rsplit("startxref").next().unwrap().split_whitespace().next().unwrap().parse().unwrap();
let offset = bytes.len();
let mut entry = vec![1];
entry.extend_from_slice(&u32::try_from(offset).unwrap().to_be_bytes());
entry.extend_from_slice(&0u16.to_be_bytes());
bytes.extend_from_slice(format!("7 0 obj\n<</Type/XRef/Size 8/Root 1 2 R/Prev {previous}/W[1 4 2]/Index[7 1]/Length 7>>\nstream\n").as_bytes());
bytes.extend_from_slice(&entry);
bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes());
bytes
}
fn assemble(objects: &[String], generations: &[i32]) -> Vec<u8> {
let mut bytes = b"%PDF-1.5\n".to_vec();
let mut offsets = Vec::new();
for (index, body) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} {} obj\n{body}\nendobj\n", index + 1, generations[index]).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes());
for (offset, generation) in offsets.iter().zip(generations) {
bytes.extend_from_slice(format!("{offset:010} {generation:05} n\r\n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<</Size {}/Root 1 2 R>>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
pub fn document(bytes: Vec<u8>, hidden_sets: &[&[&str]]) -> Document {
let width = 100.0 * f64::from(u32::try_from(hidden_sets.len()).unwrap());
let mut doc = Document::new(&NewDocument { width, height: 100.0, facing_pages: false, ..Default::default() });
let asset = AssetId(doc.alloc());
doc.add_asset(Asset {
id: asset,
name: "layers.pdf".into(),
mime: "application/pdf".into(),
data: Arc::new(bytes),
link: None,
link_fingerprint: None,
pixels: None,
page: 0,
pdf_crop: Default::default(),
pdf_box: None,
});
for (index, hidden) in hidden_sets.iter().enumerate() {
let x = 100.0 * f64::from(u32::try_from(index).unwrap());
let mut item = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)));
item.xf = Affine::translate((x, 0.0));
item.pdf_hidden_layers = hidden.iter().map(|name| (*name).to_owned()).collect();
item.content = Content::Graphic(Graphic {
asset,
size: (100.0, 100.0),
xf: Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
layer_options: None,
});
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
}
doc
}
pub fn refs(dict: &Dict<'_>, key: &[u8]) -> Vec<ObjectIdentifier> {
dict.get::<Array<'_>>(key).unwrap().raw_iter().map(|value| value.as_obj_ref().unwrap().into()).collect()
}
pub fn catalog(bytes: &[u8]) -> Vec<(String, bool)> {
let pdf = Pdf::new(bytes.to_vec()).unwrap();
let root = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
let properties = root.get::<Dict<'_>>(b"OCProperties").expect("export must register its embedded OCGs");
let groups = refs(&properties, b"OCGs");
let defaults = properties.get::<Dict<'_>>(b"D").unwrap();
assert!(defaults.get::<hayro_syntax::object::String<'_>>(b"Name").is_some());
assert_eq!(defaults.get::<Name<'_>>(b"BaseState").unwrap().as_ref(), b"ON");
assert!(!defaults.contains_key(b"AS"));
assert_eq!(refs(&defaults, b"Order"), groups);
let off = refs(&defaults, b"OFF");
assert!(off.iter().all(|id| groups.contains(id)));
let mut unique = groups.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), groups.len());
let mut result = Vec::new();
for id in groups {
let group = pdf.xref().get::<Dict<'_>>(id).unwrap();
assert!(group.contains_key(b"Usage"));
assert!(group.contains_key(b"Intent"));
assert!(group.contains_key(b"Custom"));
let custom = group.get::<hayro_syntax::object::String<'_>>(b"Custom").unwrap();
assert!([b"keep (this) >>".as_slice(), &[0, 255, 128]].contains(&custom.as_bytes()));
let intent = group.get::<Array<'_>>(b"Intent").unwrap();
assert_eq!(intent.iter::<Name<'_>>().map(|name| name.as_ref().to_vec()).collect::<Vec<_>>(), [b"View".to_vec(), b"Design".to_vec()]);
let usage = group.get::<Dict<'_>>(b"Usage").unwrap().get::<Dict<'_>>(b"Print").unwrap();
assert_eq!(usage.get::<Name<'_>>(b"PrintState").unwrap().as_ref(), b"ON");
let name = group.get::<hayro_syntax::object::String<'_>>(b"Name").unwrap();
result.push((String::from_utf8(name.as_bytes().to_vec()).unwrap(), off.contains(&id)));
}
result.sort();
result
}
pub fn painted(bytes: &[u8], expected: &[bool]) {
let width = u32::try_from(expected.len()).unwrap() * 50;
let image = designcraft_render::render_pdf_page(bytes, 0, width.max(100)).unwrap();
for (index, painted) in expected.iter().enumerate() {
let image_width = usize::from(image.width());
let x = (2 * index + 1) * image_width / (2 * expected.len());
let y = usize::from(image.height()) / 2;
let pixel = image.data()[y * image_width + x];
assert_eq!(pixel.a > 200, *painted, "half {index}: {pixel:?}");
}
}