1use std::cell::{Cell, RefCell};
27#[cfg(feature = "docx")]
28use std::collections::HashMap;
29use std::rc::Rc;
30#[cfg(feature = "docx")]
31use std::sync::Arc;
32use std::time::Instant;
33
34#[cfg(feature = "docx")]
35use crate::adapter::docx::wml::StoryModel;
36#[cfg(feature = "docx")]
37use crate::adapter::docx::{DocxExtractProfile, DocxModel, DocxPartRef, DocxStory, story_params};
38#[cfg(feature = "epub")]
39use crate::adapter::epub::{EpubExtractProfile, EpubModel, ManifestItem, PackageDoc};
40#[cfg(feature = "odt")]
41use crate::adapter::odt::{
42 Block as OdtBlock, ContentModel as OdtContentModel, OdtExtractProfile, OdtModel,
43};
44use crate::error::{Error, Result};
45use crate::field::cache::DerivedCache;
46use crate::field::dag::{self, EvalBudget, OutputCache, ReuseStats, SourceServer};
47use crate::field::document_format::DocumentFormat;
48#[cfg(feature = "docx")]
49use crate::field::index::SEL_DOCX_MODEL;
50#[cfg(feature = "epub")]
51use crate::field::index::SEL_EPUB_MODEL;
52#[cfg(feature = "odt")]
53use crate::field::index::SEL_ODT_MODEL;
54#[cfg(feature = "opc")]
55use crate::field::index::SEL_OPC_MODEL;
56use crate::field::index::{
57 FsIndexStore, IndexEntry, SEL_OBJECT, SEL_PACKAGE_MEMBER_DECODED, SEL_PACKAGE_MEMBER_RAW,
58 SEL_PAGE, SEL_REVISION, SEL_STREAM, SEL_STREAM_DECODED, SelectorKey, lookup,
59};
60use crate::field::ingest;
61use crate::field::manifest::FieldRoot;
62use crate::field::node::{NodeKind, SeedNode, read_u32_params, span_params, u32_params};
63use crate::field::partial::{PartialDescriptor, PartialLoad};
64use crate::field::promote::GovernedCache;
65use crate::field::{Field, FieldId, FieldStore, SeedSubstrate};
66use crate::limits::Limits;
67use crate::store::{Id, IoSnapshot, NodeId, SeedStore};
68
69use super::provenance::{AnswerValue, Basis, FieldAnswer, IntegrityScope, json_escape};
70
71const MAX_TEXTMATCH_PAGES: u32 = 1 << 20;
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Selector {
77 Document,
79 Page(u32),
81 Object(u32),
83 Stream(u32),
85 Revision(u32),
87 Member(u32),
90 PackagePart(String),
94 Relationship(String),
97 ByteRange {
99 offset: u64,
101 len: u64,
103 },
104 TextMatch(String),
106 Metadata,
110 Text,
112 Heading(u32),
114 Block(u32),
116 Table(u32),
118 Cell {
120 table: u32,
122 row: u32,
124 col: u32,
126 },
127 Resource(u32),
130 Link(u32),
132 SearchMatch(String),
135 #[cfg(feature = "docx")]
138 DocxStory {
139 story: DocxStory,
141 profile: DocxExtractProfile,
143 },
144 #[cfg(feature = "docx")]
146 DocxParagraph {
147 story: DocxStory,
149 index: u32,
151 profile: DocxExtractProfile,
153 },
154 #[cfg(feature = "docx")]
156 DocxTable {
157 story: DocxStory,
159 index: u32,
161 profile: DocxExtractProfile,
163 },
164 #[cfg(feature = "docx")]
166 DocxCell {
167 story: DocxStory,
169 table: u32,
171 cell: String,
173 profile: DocxExtractProfile,
175 },
176 #[cfg(feature = "docx")]
178 DocxFind {
179 story: DocxStory,
181 pattern: String,
183 profile: DocxExtractProfile,
185 },
186 #[cfg(feature = "epub")]
188 EpubPackage,
189 #[cfg(feature = "epub")]
193 EpubManifestItem {
194 index: u32,
196 },
197 #[cfg(feature = "epub")]
201 EpubSpineItem {
202 index: u32,
204 profile: EpubExtractProfile,
206 },
207 #[cfg(feature = "epub")]
209 EpubNav,
210 #[cfg(feature = "epub")]
212 EpubNavNode {
213 index: u32,
215 },
216 #[cfg(feature = "epub")]
219 EpubResource(String),
220 #[cfg(feature = "epub")]
224 EpubBlock {
225 index: u32,
227 block: u32,
229 profile: EpubExtractProfile,
231 },
232 #[cfg(feature = "epub")]
236 EpubCell {
237 index: u32,
239 table: u32,
241 row: u32,
243 col: u32,
245 profile: EpubExtractProfile,
247 },
248 #[cfg(feature = "epub")]
250 EpubLink {
251 index: u32,
253 link: u32,
255 profile: EpubExtractProfile,
257 },
258 #[cfg(feature = "epub")]
260 EpubFind {
261 index: u32,
263 pattern: String,
265 profile: EpubExtractProfile,
267 },
268 #[cfg(feature = "odt")]
272 OdtPart(String),
273 #[cfg(feature = "odt")]
276 OdtParagraph {
277 index: u32,
279 profile: OdtExtractProfile,
281 },
282 #[cfg(feature = "odt")]
285 OdtHeading {
286 index: u32,
288 profile: OdtExtractProfile,
290 },
291 #[cfg(feature = "odt")]
293 OdtTable {
294 index: u32,
296 profile: OdtExtractProfile,
298 },
299 #[cfg(feature = "odt")]
302 OdtCell {
303 table: u32,
305 row: u32,
307 col: u32,
309 profile: OdtExtractProfile,
311 },
312 #[cfg(feature = "odt")]
314 OdtList {
315 index: u32,
317 profile: OdtExtractProfile,
319 },
320 #[cfg(feature = "odt")]
322 OdtFind {
323 pattern: String,
325 profile: OdtExtractProfile,
327 },
328}
329
330impl Selector {
331 pub fn canonical(&self) -> String {
333 match self {
334 Selector::Document => "document".to_string(),
335 Selector::Page(n) => format!("page:{n}"),
336 Selector::Object(n) => format!("object:{n}"),
337 Selector::Stream(n) => format!("stream:{n}"),
338 Selector::Revision(n) => format!("revision:{n}"),
339 Selector::Member(n) => format!("member:{n}"),
340 Selector::PackagePart(name) => format!("package-part:{name}"),
341 Selector::Relationship(id) => format!("relationship:{id}"),
342 Selector::ByteRange { offset, len } => format!("byte-range:{offset}:{len}"),
343 Selector::TextMatch(p) => format!("text-match:{p}"),
344 Selector::Metadata => "metadata".to_string(),
345 Selector::Text => "text".to_string(),
346 Selector::Heading(n) => format!("heading:{n}"),
347 Selector::Block(n) => format!("block:{n}"),
348 Selector::Table(n) => format!("table:{n}"),
349 Selector::Cell { table, row, col } => format!("cell:{table}:{row}:{col}"),
350 Selector::Resource(n) => format!("resource:{n}"),
351 Selector::Link(n) => format!("link:{n}"),
352 Selector::SearchMatch(p) => format!("search-match:{p}"),
353 #[cfg(feature = "docx")]
354 Selector::DocxStory { story, profile } => {
355 format!(
356 "docx-story:{};profile={}",
357 story.name(),
358 profile.fingerprint()
359 )
360 }
361 #[cfg(feature = "docx")]
362 Selector::DocxParagraph {
363 story,
364 index,
365 profile,
366 } => format!(
367 "docx-paragraph:{}:{};profile={}",
368 story.name(),
369 index,
370 profile.fingerprint()
371 ),
372 #[cfg(feature = "docx")]
373 Selector::DocxTable {
374 story,
375 index,
376 profile,
377 } => format!(
378 "docx-table:{}:{};profile={}",
379 story.name(),
380 index,
381 profile.fingerprint()
382 ),
383 #[cfg(feature = "docx")]
384 Selector::DocxCell {
385 story,
386 table,
387 cell,
388 profile,
389 } => format!(
390 "docx-cell:{}:{}:{};profile={}",
391 story.name(),
392 table,
393 cell,
394 profile.fingerprint()
395 ),
396 #[cfg(feature = "docx")]
397 Selector::DocxFind {
398 story,
399 pattern,
400 profile,
401 } => format!(
402 "docx-find:{}:{};profile={}",
403 story.name(),
404 pattern,
405 profile.fingerprint()
406 ),
407 #[cfg(feature = "epub")]
408 Selector::EpubPackage => "epub-package".to_string(),
409 #[cfg(feature = "epub")]
410 Selector::EpubManifestItem { index } => format!("epub-manifest-item:{index}"),
411 #[cfg(feature = "epub")]
412 Selector::EpubSpineItem { index, profile } => {
413 format!("epub-spine-item:{index};profile={}", profile.fingerprint())
414 }
415 #[cfg(feature = "epub")]
416 Selector::EpubNav => "epub-nav".to_string(),
417 #[cfg(feature = "epub")]
418 Selector::EpubNavNode { index } => format!("epub-nav-node:{index}"),
419 #[cfg(feature = "epub")]
420 Selector::EpubResource(name) => format!("epub-resource:{name}"),
421 #[cfg(feature = "epub")]
422 Selector::EpubBlock {
423 index,
424 block,
425 profile,
426 } => format!(
427 "epub-block:{index}:{block};profile={}",
428 profile.fingerprint()
429 ),
430 #[cfg(feature = "epub")]
431 Selector::EpubCell {
432 index,
433 table,
434 row,
435 col,
436 profile,
437 } => format!(
438 "epub-cell:{index}:{table}:{row}:{col};profile={}",
439 profile.fingerprint()
440 ),
441 #[cfg(feature = "epub")]
442 Selector::EpubLink {
443 index,
444 link,
445 profile,
446 } => format!("epub-link:{index}:{link};profile={}", profile.fingerprint()),
447 #[cfg(feature = "epub")]
448 Selector::EpubFind {
449 index,
450 pattern,
451 profile,
452 } => format!(
453 "epub-find:{index}:{pattern};profile={}",
454 profile.fingerprint()
455 ),
456 #[cfg(feature = "odt")]
457 Selector::OdtPart(name) => format!("odt-part:{name}"),
458 #[cfg(feature = "odt")]
459 Selector::OdtParagraph { index, profile } => {
460 format!("odt-paragraph:{index};profile={}", profile.fingerprint())
461 }
462 #[cfg(feature = "odt")]
463 Selector::OdtHeading { index, profile } => {
464 format!("odt-heading:{index};profile={}", profile.fingerprint())
465 }
466 #[cfg(feature = "odt")]
467 Selector::OdtTable { index, profile } => {
468 format!("odt-table:{index};profile={}", profile.fingerprint())
469 }
470 #[cfg(feature = "odt")]
471 Selector::OdtCell {
472 table,
473 row,
474 col,
475 profile,
476 } => format!(
477 "odt-cell:{table}:{row}:{col};profile={}",
478 profile.fingerprint()
479 ),
480 #[cfg(feature = "odt")]
481 Selector::OdtList { index, profile } => {
482 format!("odt-list:{index};profile={}", profile.fingerprint())
483 }
484 #[cfg(feature = "odt")]
485 Selector::OdtFind { pattern, profile } => {
486 format!("odt-find:{pattern};profile={}", profile.fingerprint())
487 }
488 }
489 }
490
491 pub fn is_common(&self) -> bool {
495 matches!(
496 self,
497 Selector::Metadata
498 | Selector::Text
499 | Selector::Heading(_)
500 | Selector::Block(_)
501 | Selector::Table(_)
502 | Selector::Cell { .. }
503 | Selector::Resource(_)
504 | Selector::Link(_)
505 | Selector::SearchMatch(_)
506 )
507 }
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub enum Representation {
513 Metadata,
515 Text,
517 Structure,
519 Operators,
521 EncodedBytes,
523 DecodedBytes,
525 ExactBytes,
527 Preview,
529 FullDocument,
531}
532
533impl Representation {
534 pub const fn name(self) -> &'static str {
536 match self {
537 Representation::Metadata => "metadata",
538 Representation::Text => "text",
539 Representation::Structure => "structure",
540 Representation::Operators => "operators",
541 Representation::EncodedBytes => "encoded",
542 Representation::DecodedBytes => "decoded",
543 Representation::ExactBytes => "exact",
544 Representation::Preview => "preview",
545 Representation::FullDocument => "full",
546 }
547 }
548}
549
550#[derive(Debug, Clone, Copy)]
552pub struct ObserveBudget {
553 pub max_output_bytes: u64,
555 pub max_nodes: u64,
557}
558
559impl Default for ObserveBudget {
560 fn default() -> Self {
561 ObserveBudget {
562 max_output_bytes: 64 * 1024 * 1024,
563 max_nodes: 1 << 20,
564 }
565 }
566}
567
568#[derive(Debug, Clone)]
570pub struct ObserveRequest {
571 pub selector: Selector,
573 pub representation: Representation,
575 pub budget: ObserveBudget,
577 pub use_cache: bool,
580}
581
582impl ObserveRequest {
583 pub fn new(selector: Selector, representation: Representation) -> Self {
585 ObserveRequest {
586 selector,
587 representation,
588 budget: ObserveBudget::default(),
589 use_cache: true,
590 }
591 }
592}
593
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
596pub enum DescriptorReadMode {
597 #[default]
599 Full,
600 Partial,
602}
603
604impl DescriptorReadMode {
605 pub const fn name(self) -> &'static str {
607 match self {
608 DescriptorReadMode::Full => "full",
609 DescriptorReadMode::Partial => "partial",
610 }
611 }
612}
613
614#[derive(Debug, Clone, Default, PartialEq, Eq)]
621pub struct ObserveStats {
622 pub index_nodes_read: u64,
625 pub seed_nodes_fetched: u64,
627 pub seed_nodes_materialized: u64,
631 pub seed_nodes_executed: u64,
633 pub seed_nodes_reused: u64,
636 pub cache_bytes_written: u64,
638 pub nodes_id_shared: u64,
644 pub shared_resource_ids: u64,
647 pub descriptor_bytes_read: u64,
652 pub descriptor_read_mode: DescriptorReadMode,
655 pub manifest_bytes_read: u64,
657 pub index_bytes_read: u64,
659 pub seed_bytes_read: u64,
661 pub bytes_read: u64,
666 pub bytes_returned: u64,
668 pub deepened: bool,
670 pub member_decodes: u64,
676 pub xml_parses: u64,
680 pub wall_micros: u64,
682}
683
684struct CountingSeedStore<S: SeedStore> {
689 inner: S,
690 gets: Cell<u64>,
691}
692
693impl<S: SeedStore> CountingSeedStore<S> {
694 fn new(inner: S) -> Self {
695 CountingSeedStore {
696 inner,
697 gets: Cell::new(0),
698 }
699 }
700
701 fn gets(&self) -> u64 {
702 self.gets.get()
703 }
704
705 fn note(&self) {
706 self.gets.set(self.gets.get() + 1);
707 }
708}
709
710impl<S: SeedStore> SeedStore for CountingSeedStore<S> {
711 fn put_node(&mut self, canonical: &[u8]) -> Result<NodeId> {
712 self.inner.put_node(canonical)
713 }
714
715 fn get_node(&self, id: &NodeId) -> Result<Vec<u8>> {
716 self.note();
717 self.inner.get_node(id)
718 }
719
720 fn get_node_range(&self, id: &NodeId, offset: u64, len: u64) -> Result<Vec<u8>> {
721 self.note();
722 self.inner.get_node_range(id, offset, len)
723 }
724
725 fn contains_node(&self, id: &NodeId) -> Result<bool> {
726 self.inner.contains_node(id)
727 }
728
729 fn list_nodes(&self) -> Result<Vec<(NodeId, u64)>> {
730 self.inner.list_nodes()
731 }
732}
733
734pub fn observe(
741 store: &mut FieldStore,
742 id: &FieldId,
743 req: &ObserveRequest,
744 limits: Limits,
745) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
746 let started = Instant::now();
747 match narrow_probe(store, id, req)? {
748 NarrowProbe::Probed {
753 hit: true,
754 manifest,
755 carry,
756 } => {
757 let view = FieldView {
758 manifest: manifest.as_ref(),
759 id: *id,
760 open_io: IoSnapshot::default(),
761 source: &NO_SOURCE,
762 loader: None,
763 object_count: 0,
764 graph_ops: 0,
765 read_mode: DescriptorReadMode::Partial,
766 };
767 let (seeds, istore) = open_sub_stores(store)?;
768 observe_with_stores_pre(
769 store,
770 view,
771 req,
772 limits,
773 started,
774 seeds,
775 &istore,
776 carry,
777 ModelMemo::default(),
778 )
779 }
780 NarrowProbe::Probed {
785 hit: false,
786 manifest,
787 carry,
788 } => {
789 let opened = OpenedField::open_with_manifest(store, req, *manifest, limits)?;
790 observe_view_pre(store, opened.view(), req, limits, started, carry)
791 }
792 NarrowProbe::NotEligible => {
793 let opened = OpenedField::open(store, id, req, limits)?;
794 observe_view(store, opened.view(), req, limits, started)
795 }
796 }
797}
798
799struct NoSource;
804
805static NO_SOURCE: NoSource = NoSource;
806
807impl SourceServer for NoSource {
808 fn serve_range(&self, _offset: u64, _len: u64, _limits: Limits) -> Result<Vec<u8>> {
809 Err(Error::internal_invariant(
810 "a cache-served observation attempted a descriptor range read",
811 ))
812 }
813
814 fn serve_document(&self, _limits: Limits) -> Result<Vec<u8>> {
815 Err(Error::internal_invariant(
816 "a cache-served observation attempted a descriptor document read",
817 ))
818 }
819}
820
821#[derive(Default)]
824struct PrefetchedIndex {
825 entries: Vec<(SelectorKey, Vec<IndexEntry>)>,
826}
827
828impl PrefetchedIndex {
829 fn insert(&mut self, key: SelectorKey, entries: Vec<IndexEntry>) {
830 self.entries.push((key, entries));
831 }
832
833 fn get(&self, key: &SelectorKey) -> Option<&Vec<IndexEntry>> {
834 self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
835 }
836}
837
838#[derive(Default)]
841struct ProbeCarry {
842 base_io: IoSnapshot,
844 prefetched: PrefetchedIndex,
846 output: Option<(NodeId, Vec<u8>)>,
849}
850
851enum NarrowProbe {
853 NotEligible,
855 Probed {
858 hit: bool,
859 manifest: Box<FieldRoot>,
860 carry: ProbeCarry,
861 },
862}
863
864fn probe_eligible(store: &FieldStore, req: &ObserveRequest) -> bool {
868 use Representation as R;
869 req.use_cache
873 && store.supports_partial_descriptor()
874 && matches!(
875 (&req.selector, req.representation),
876 (Selector::Page(_), R::Text | R::Preview | R::Structure)
877 | (Selector::Stream(_), R::DecodedBytes | R::Operators)
878 )
879}
880
881fn narrow_probe_open(
886 store: &FieldStore,
887 manifest: &FieldRoot,
888 istore: &FsIndexStore,
889 req: &ObserveRequest,
890) -> Result<NarrowProbe> {
891 if !probe_eligible(store, req) {
892 return Ok(NarrowProbe::NotEligible);
893 }
894 let io_before = store.io().snapshot();
895 narrow_probe_core(store, manifest, istore, io_before, req)
896}
897
898fn narrow_probe(store: &FieldStore, id: &FieldId, req: &ObserveRequest) -> Result<NarrowProbe> {
909 if !probe_eligible(store, req) {
910 return Ok(NarrowProbe::NotEligible);
911 }
912 let io_before = store.io().snapshot();
913 let manifest = store.get_field(id)?;
914 let istore = FsIndexStore::open_with_io(store.root(), store.io().handle())?;
915 narrow_probe_core(store, &manifest, &istore, io_before, req)
916}
917
918fn narrow_probe_core(
920 store: &FieldStore,
921 manifest: &FieldRoot,
922 istore: &FsIndexStore,
923 io_before: IoSnapshot,
924 req: &ObserveRequest,
925) -> Result<NarrowProbe> {
926 use Representation as R;
927 let mut prefetched = PrefetchedIndex::default();
928 if !manifest.has_index() {
929 let base_io = io_before.delta(&store.io().snapshot());
931 return Ok(NarrowProbe::Probed {
932 hit: false,
933 manifest: Box::new(manifest.clone()),
934 carry: ProbeCarry {
935 base_io,
936 prefetched,
937 output: None,
938 },
939 });
940 }
941
942 let root = NodeId::from_bytes(manifest.index_root);
943 let seeds = store.seed_substrate();
944
945 let target: Option<(NodeId, u64)> = match (&req.selector, req.representation) {
947 (Selector::Page(page), R::Text | R::Preview | R::Structure) => {
948 let key = SelectorKey::new(SEL_PAGE, *page);
949 let entries = lookup(istore, &root, &key)?;
950 prefetched.insert(key, entries.clone());
951 match entries.first() {
952 Some(entry) => {
953 let (ops, text, preview) = derived_nodes(*page, entry.node_id);
954 if seeds.contains_node(&ops.content_id())?
958 && seeds.contains_node(&text.content_id())?
959 && seeds.contains_node(&preview.content_id())?
960 {
961 let node = match req.representation {
962 R::Preview | R::Structure => preview,
963 _ => text,
964 };
965 Some((node.content_id(), node.limits.max_output_bytes))
966 } else {
967 None
968 }
969 }
970 None => None,
971 }
972 }
973 (Selector::Stream(object), R::DecodedBytes | R::Operators) => {
974 let enc_key = SelectorKey::new(SEL_STREAM, *object);
975 let enc = lookup(istore, &root, &enc_key)?;
976 prefetched.insert(enc_key, enc.clone());
977 let dec_key = SelectorKey::new(SEL_STREAM_DECODED, *object);
978 let dec = lookup(istore, &root, &dec_key)?;
979 prefetched.insert(dec_key, dec.clone());
980 if enc.is_empty() {
984 None
985 } else {
986 match dec.first() {
987 Some(entry) if req.representation == R::DecodedBytes => Some((
990 entry.node_id,
991 crate::field::node::NodeLimits::DEFAULT.max_output_bytes,
992 )),
993 Some(entry) => {
994 let node = SeedNode::new(
995 NodeKind::ContentOperators,
996 0,
997 Vec::new(),
998 vec![entry.node_id],
999 "pdf:content-operators",
1000 );
1001 Some((node.content_id(), node.limits.max_output_bytes))
1002 }
1003 None => None,
1004 }
1005 }
1006 }
1007 _ => None,
1008 };
1009
1010 let (hit, output) = match target {
1013 Some((target_id, max_output_bytes)) => {
1016 match DerivedCache::open(store.root().join("cache"))?.get(&target_id) {
1017 Ok(Some(bytes)) if bytes.len() as u64 <= max_output_bytes => {
1018 (true, Some((target_id, bytes)))
1019 }
1020 _ => (false, None),
1021 }
1022 }
1023 None => (false, None),
1024 };
1025 let base_io = io_before.delta(&store.io().snapshot());
1026 Ok(NarrowProbe::Probed {
1027 hit,
1028 manifest: Box::new(manifest.clone()),
1029 carry: ProbeCarry {
1030 base_io,
1031 prefetched,
1032 output,
1033 },
1034 })
1035}
1036
1037pub(crate) fn observe_opened(
1040 store: &mut FieldStore,
1041 opened: &OpenedField,
1042 req: &ObserveRequest,
1043 limits: Limits,
1044) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1045 let started = Instant::now();
1046 observe_view(store, opened.view(), req, limits, started)
1047}
1048
1049pub fn observe_with_field(
1057 store: &mut FieldStore,
1058 field: &Field,
1059 req: &ObserveRequest,
1060 limits: Limits,
1061) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1062 let started = Instant::now();
1063 observe_view(store, FieldView::from_field(field), req, limits, started)
1064}
1065
1066pub(crate) fn open_session_index(store: &FieldStore) -> Result<FsIndexStore> {
1070 FsIndexStore::open_with_io(store.root(), store.io().handle())
1071}
1072
1073pub(crate) fn observe_session(
1087 store: &mut FieldStore,
1088 field: &Field,
1089 index: &FsIndexStore,
1090 open_io: IoSnapshot,
1091 req: &ObserveRequest,
1092 limits: Limits,
1093 models: ModelMemo,
1094) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1095 let started = Instant::now();
1096 match narrow_probe_open(store, field.manifest(), index, req)? {
1097 NarrowProbe::Probed {
1103 hit: true,
1104 manifest,
1105 carry,
1106 } => {
1107 let view = FieldView {
1108 manifest: manifest.as_ref(),
1109 id: field.id(),
1110 open_io,
1111 source: &NO_SOURCE,
1112 loader: None,
1113 object_count: 0,
1114 graph_ops: 0,
1115 read_mode: DescriptorReadMode::Partial,
1116 };
1117 let seeds = CountingSeedStore::new(store.seed_substrate());
1118 observe_with_stores_pre(
1119 store, view, req, limits, started, seeds, index, carry, models,
1120 )
1121 }
1122 NarrowProbe::Probed {
1127 hit: false, carry, ..
1128 } => {
1129 let mut view = FieldView::from_field(field);
1130 view.open_io = open_io;
1131 let seeds = CountingSeedStore::new(store.seed_substrate());
1132 observe_with_stores_pre(
1133 store, view, req, limits, started, seeds, index, carry, models,
1134 )
1135 }
1136 NarrowProbe::NotEligible => {
1137 let mut view = FieldView::from_field(field);
1138 view.open_io = open_io;
1139 let seeds = CountingSeedStore::new(store.seed_substrate());
1140 observe_with_stores_pre(
1141 store,
1142 view,
1143 req,
1144 limits,
1145 started,
1146 seeds,
1147 index,
1148 ProbeCarry::default(),
1149 models,
1150 )
1151 }
1152 }
1153}
1154
1155pub(crate) enum OpenedField {
1159 Full(Box<Field>),
1161 Partial(Box<PartialField>),
1163}
1164
1165impl OpenedField {
1166 pub(crate) fn open(
1168 store: &FieldStore,
1169 id: &FieldId,
1170 req: &ObserveRequest,
1171 limits: Limits,
1172 ) -> Result<OpenedField> {
1173 if store.supports_partial_descriptor()
1174 && partial_eligible(req)
1175 && let Some(pf) = PartialField::try_open(store, id, limits)?
1176 {
1177 return Ok(OpenedField::Partial(Box::new(pf)));
1178 }
1179 Ok(OpenedField::Full(Box::new(Field::open(store, id, limits)?)))
1180 }
1181
1182 pub(crate) fn open_with_manifest(
1186 store: &FieldStore,
1187 req: &ObserveRequest,
1188 manifest: FieldRoot,
1189 limits: Limits,
1190 ) -> Result<OpenedField> {
1191 if store.supports_partial_descriptor()
1192 && partial_eligible(req)
1193 && let Some(pf) =
1194 PartialField::finish_open(store, manifest.clone(), store.io().snapshot(), limits)?
1195 {
1196 return Ok(OpenedField::Partial(Box::new(pf)));
1197 }
1198 Ok(OpenedField::Full(Box::new(Field::open_after_manifest(
1199 store,
1200 manifest,
1201 store.io().snapshot(),
1202 limits,
1203 )?)))
1204 }
1205
1206 pub(crate) fn view(&self) -> FieldView<'_> {
1208 match self {
1209 OpenedField::Full(f) => FieldView::from_field(f),
1210 OpenedField::Partial(p) => p.view(),
1211 }
1212 }
1213
1214 pub(crate) fn manifest(&self) -> &FieldRoot {
1216 match self {
1217 OpenedField::Full(f) => f.manifest(),
1218 OpenedField::Partial(p) => &p.manifest,
1219 }
1220 }
1221}
1222
1223pub(crate) struct FieldView<'a> {
1226 pub manifest: &'a FieldRoot,
1227 pub id: FieldId,
1228 pub open_io: IoSnapshot,
1229 pub source: &'a dyn SourceServer,
1230 pub loader: Option<&'a PartialDescriptor>,
1233 pub object_count: usize,
1234 pub graph_ops: usize,
1235 pub read_mode: DescriptorReadMode,
1236}
1237
1238impl<'a> FieldView<'a> {
1239 pub(crate) fn from_field(field: &'a Field) -> FieldView<'a> {
1240 let parsed = field.parsed();
1241 FieldView {
1242 manifest: field.manifest(),
1243 id: field.id(),
1244 open_io: field.open_io(),
1245 source: parsed,
1246 loader: None,
1247 object_count: parsed.descriptor.objects.len(),
1248 graph_ops: parsed.descriptor.program.ops.len(),
1249 read_mode: DescriptorReadMode::Full,
1250 }
1251 }
1252}
1253
1254pub(crate) struct PartialField {
1256 pub(crate) manifest: FieldRoot,
1257 pub(crate) id: FieldId,
1258 pub(crate) open_io: IoSnapshot,
1259 loader: PartialDescriptor,
1260}
1261
1262impl PartialField {
1263 pub(crate) fn try_open(
1267 store: &FieldStore,
1268 id: &FieldId,
1269 limits: Limits,
1270 ) -> Result<Option<PartialField>> {
1271 let io_before = store.io().snapshot();
1272 let manifest = store.get_field(id)?;
1273 PartialField::finish_open(store, manifest, io_before, limits)
1274 }
1275
1276 pub(crate) fn finish_open(
1283 store: &FieldStore,
1284 manifest: FieldRoot,
1285 io_before: IoSnapshot,
1286 limits: Limits,
1287 ) -> Result<Option<PartialField>> {
1288 let descriptor_id = Id::from_bytes(manifest.descriptor_id);
1289 let Some(path) = store.descriptor_path(&descriptor_id) else {
1290 return Ok(None);
1294 };
1295 let loader = match PartialDescriptor::open(&path, limits)? {
1296 PartialLoad::Ready(l) => l,
1297 PartialLoad::Ineligible { bytes_read } => {
1298 store.io().add_descriptor(bytes_read);
1301 return Ok(None);
1302 }
1303 };
1304 if loader.source_len() != manifest.source_len
1305 || loader.source_sha256() != manifest.source_sha256
1306 {
1307 return Err(Error::integrity_mismatch(
1308 "partial descriptor does not match its field manifest's declared source",
1309 ));
1310 }
1311 let open_io = io_before.delta(&store.io().snapshot());
1315 let id = manifest.content_id();
1316 Ok(Some(PartialField {
1317 id,
1318 manifest,
1319 open_io,
1320 loader: *loader,
1321 }))
1322 }
1323
1324 pub(crate) fn view(&self) -> FieldView<'_> {
1325 FieldView {
1326 manifest: &self.manifest,
1327 id: self.id,
1328 open_io: self.open_io,
1329 source: &self.loader,
1330 loader: Some(&self.loader),
1331 object_count: self.loader.object_count(),
1332 graph_ops: self.loader.graph_ops(),
1333 read_mode: DescriptorReadMode::Partial,
1334 }
1335 }
1336}
1337
1338fn partial_eligible(req: &ObserveRequest) -> bool {
1340 use Representation as R;
1341 matches!(
1342 (&req.selector, req.representation),
1343 (Selector::ByteRange { .. }, R::ExactBytes)
1344 | (Selector::Object(_), R::ExactBytes | R::EncodedBytes)
1345 | (Selector::Revision(_), R::ExactBytes)
1346 | (Selector::Stream(_), R::EncodedBytes)
1347 | (Selector::Member(_), R::EncodedBytes | R::DecodedBytes)
1348 | (Selector::Page(_), R::Text | R::Preview | R::Structure)
1349 )
1350}
1351
1352fn open_sub_stores(store: &FieldStore) -> Result<(CountingSeedStore<SeedSubstrate>, FsIndexStore)> {
1354 let io = store.io();
1355 let seeds = CountingSeedStore::new(store.seed_substrate());
1356 let istore = FsIndexStore::open_with_io(store.root(), io.handle())?;
1357 Ok((seeds, istore))
1358}
1359
1360fn observe_view<'a>(
1361 store: &'a mut FieldStore,
1362 view: FieldView<'a>,
1363 req: &ObserveRequest,
1364 limits: Limits,
1365 started: Instant,
1366) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1367 observe_view_pre(store, view, req, limits, started, ProbeCarry::default())
1368}
1369
1370fn observe_view_pre<'a>(
1374 store: &'a mut FieldStore,
1375 view: FieldView<'a>,
1376 req: &ObserveRequest,
1377 limits: Limits,
1378 started: Instant,
1379 carry: ProbeCarry,
1380) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1381 let (seeds, istore) = open_sub_stores(store)?;
1382 observe_with_stores_pre(
1383 store,
1384 view,
1385 req,
1386 limits,
1387 started,
1388 seeds,
1389 &istore,
1390 carry,
1391 ModelMemo::default(),
1392 )
1393}
1394
1395#[cfg(test)]
1398fn observe_with_stores<'a, S: SeedStore>(
1399 store: &'a mut FieldStore,
1400 view: FieldView<'a>,
1401 req: &ObserveRequest,
1402 limits: Limits,
1403 started: Instant,
1404 seeds: CountingSeedStore<S>,
1405 istore: FsIndexStore,
1406) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1407 observe_with_stores_pre(
1408 store,
1409 view,
1410 req,
1411 limits,
1412 started,
1413 seeds,
1414 &istore,
1415 ProbeCarry::default(),
1416 ModelMemo::default(),
1417 )
1418}
1419
1420#[allow(clippy::too_many_arguments)]
1424fn observe_with_stores_pre<'a, S: SeedStore>(
1425 store: &'a mut FieldStore,
1426 view: FieldView<'a>,
1427 req: &ObserveRequest,
1428 limits: Limits,
1429 started: Instant,
1430 seeds: CountingSeedStore<S>,
1431 istore: &'a FsIndexStore,
1432 carry: ProbeCarry,
1433 models: ModelMemo,
1434) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1435 let ProbeCarry {
1436 base_io,
1437 prefetched,
1438 output,
1439 } = carry;
1440 let io_base = store.io().snapshot();
1444 let budget = EvalBudget {
1445 max_nodes: req.budget.max_nodes,
1446 ..EvalBudget::default()
1447 };
1448 let cache: Box<dyn OutputCache> = if req.use_cache && store.promote_policy().enabled {
1452 Box::new(GovernedCache::open(store.root(), store.governor())?)
1453 } else {
1454 Box::new(DerivedCache::open(store.root().join("cache"))?)
1455 };
1456 let field_id = view.id;
1457 let mut ctx = Ctx {
1458 store,
1459 manifest: view.manifest,
1460 source: view.source,
1461 loader: view.loader,
1462 open_io: view.open_io,
1463 object_count: view.object_count,
1464 graph_ops: view.graph_ops,
1465 read_mode: view.read_mode,
1466 seeds,
1467 istore,
1468 prefetched,
1469 prefetched_output: output,
1470 limits,
1471 budget,
1472 stats: ObserveStats::default(),
1473 use_cache: req.use_cache,
1474 cache,
1475 reuse: ReuseStats::default(),
1476 models,
1477 current_id: field_id,
1478 };
1479
1480 let answer = ctx.dispatch(req)?;
1481 let produced = answer.value.byte_len();
1482 if produced > req.budget.max_output_bytes {
1483 return Err(Error::resource_limit(format!(
1484 "observation produced {produced} bytes, exceeding the {}-byte budget",
1485 req.budget.max_output_bytes
1486 )));
1487 }
1488
1489 let mut stats = ctx.stats;
1490 if let Some(loader) = ctx.loader {
1494 ctx.store.io().add_descriptor(loader.bytes_read());
1495 }
1496 let open = ctx.open_io;
1500 let extra = io_base.delta(&ctx.store.io().snapshot());
1501 stats.descriptor_bytes_read = base_io
1502 .descriptor_bytes
1503 .saturating_add(open.descriptor_bytes)
1504 .saturating_add(extra.descriptor_bytes);
1505 stats.descriptor_read_mode = ctx.read_mode;
1506 stats.manifest_bytes_read = base_io
1507 .manifest_bytes
1508 .saturating_add(open.manifest_bytes)
1509 .saturating_add(extra.manifest_bytes);
1510 stats.index_bytes_read = base_io.index_bytes.saturating_add(extra.index_bytes);
1511 stats.seed_bytes_read = base_io.seed_bytes.saturating_add(extra.seed_bytes);
1512 stats.bytes_read = stats
1513 .descriptor_bytes_read
1514 .saturating_add(stats.manifest_bytes_read)
1515 .saturating_add(stats.index_bytes_read)
1516 .saturating_add(stats.seed_bytes_read);
1517 stats.seed_nodes_fetched = ctx.seeds.gets();
1518 stats.seed_nodes_materialized = ctx.budget.nodes;
1519 stats.seed_nodes_executed = ctx.reuse.nodes_executed;
1520 stats.seed_nodes_reused = ctx.reuse.nodes_reused;
1521 stats.cache_bytes_written = ctx.reuse.cache_bytes_written;
1522 stats.nodes_id_shared =
1525 crate::field::manifest::provenance_counter(view.manifest.provenance.as_str(), "id_shared");
1526 stats.shared_resource_ids =
1527 crate::field::manifest::provenance_counter(view.manifest.provenance.as_str(), "res_shared");
1528 stats.bytes_returned = produced;
1529 stats.wall_micros = started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64;
1530 Ok((answer, stats, ctx.current_id))
1531}
1532
1533#[derive(Clone, Default)]
1548pub(crate) struct ModelMemo {
1549 inner: Rc<RefCell<ModelMemoInner>>,
1550}
1551
1552#[derive(Default)]
1553struct ModelMemoInner {
1554 budget: u64,
1556 #[cfg_attr(not(feature = "docx"), allow(dead_code))]
1559 used: u64,
1560 #[cfg(feature = "docx")]
1561 docx: HashMap<NodeId, Arc<DocxModel>>,
1562 #[cfg(feature = "docx")]
1563 story: HashMap<NodeId, Arc<StoryModel>>,
1564}
1565
1566impl ModelMemo {
1567 pub(crate) fn with_budget(budget: u64) -> Self {
1568 let m = ModelMemo::default();
1569 m.inner.borrow_mut().budget = budget;
1570 m
1571 }
1572
1573 #[cfg(feature = "docx")]
1576 fn get_docx(&self, id: &NodeId) -> Option<Arc<DocxModel>> {
1577 self.inner.borrow().docx.get(id).cloned()
1578 }
1579
1580 #[cfg(feature = "docx")]
1581 fn put_docx(&self, id: NodeId, model: Arc<DocxModel>, cost: u64) {
1582 let mut inner = self.inner.borrow_mut();
1583 if inner.budget == 0 {
1584 return;
1585 }
1586 if inner.used.saturating_add(cost) > inner.budget {
1587 inner.docx.clear();
1588 inner.story.clear();
1589 inner.used = 0;
1590 }
1591 inner.used = inner.used.saturating_add(cost);
1592 inner.docx.insert(id, model);
1593 }
1594
1595 #[cfg(feature = "docx")]
1596 fn get_story(&self, id: &NodeId) -> Option<Arc<StoryModel>> {
1597 self.inner.borrow().story.get(id).cloned()
1598 }
1599
1600 #[cfg(feature = "docx")]
1601 fn put_story(&self, id: NodeId, model: Arc<StoryModel>, cost: u64) {
1602 let mut inner = self.inner.borrow_mut();
1603 if inner.budget == 0 {
1604 return;
1605 }
1606 if inner.used.saturating_add(cost) > inner.budget {
1607 inner.docx.clear();
1608 inner.story.clear();
1609 inner.used = 0;
1610 }
1611 inner.used = inner.used.saturating_add(cost);
1612 inner.story.insert(id, model);
1613 }
1614}
1615
1616struct Ctx<'a, S: SeedStore> {
1618 store: &'a mut FieldStore,
1619 manifest: &'a FieldRoot,
1620 source: &'a dyn SourceServer,
1621 loader: Option<&'a PartialDescriptor>,
1622 open_io: IoSnapshot,
1623 object_count: usize,
1624 graph_ops: usize,
1625 read_mode: DescriptorReadMode,
1626 seeds: CountingSeedStore<S>,
1627 istore: &'a FsIndexStore,
1628 prefetched: PrefetchedIndex,
1630 prefetched_output: Option<(NodeId, Vec<u8>)>,
1632 limits: Limits,
1633 budget: EvalBudget,
1634 stats: ObserveStats,
1635 use_cache: bool,
1636 cache: Box<dyn OutputCache>,
1637 reuse: ReuseStats,
1638 #[cfg_attr(not(feature = "docx"), allow(dead_code))]
1641 models: ModelMemo,
1642 current_id: FieldId,
1643}
1644
1645#[cfg(feature = "docx")]
1648struct DocxStoryView {
1649 model: Arc<StoryModel>,
1650 part: DocxPartRef,
1651 deps: Vec<NodeId>,
1652 span: Option<(u64, u64)>,
1653}
1654
1655#[cfg(feature = "docx")]
1656fn opt_u8_json(v: Option<u8>) -> String {
1657 match v {
1658 Some(n) => n.to_string(),
1659 None => "null".to_string(),
1660 }
1661}
1662
1663#[cfg(feature = "docx")]
1664fn opt_str_json(v: Option<&str>) -> String {
1665 match v {
1666 Some(s) => format!("\"{}\"", json_escape(s)),
1667 None => "null".to_string(),
1668 }
1669}
1670
1671#[cfg(feature = "docx")]
1675fn parse_cell_ref(s: &str) -> Option<(u32, u32)> {
1676 let letters: String = s.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
1677 let digits: String = s.chars().skip(letters.len()).collect();
1678 if letters.is_empty() || digits.is_empty() || digits.len() != s.len() - letters.len() {
1679 return None;
1680 }
1681 if !digits.chars().all(|c| c.is_ascii_digit()) {
1682 return None;
1683 }
1684 let mut col: u32 = 0;
1685 for c in letters.chars() {
1686 let v = c.to_ascii_uppercase() as u32 - 'A' as u32 + 1;
1687 col = col.checked_mul(26)?.checked_add(v)?;
1688 }
1689 let col = col.checked_sub(1)?;
1690 let row: u32 = digits.parse().ok()?;
1691 if row == 0 {
1692 return None;
1693 }
1694 Some((col, row - 1))
1695}
1696
1697impl<S: SeedStore> Ctx<'_, S> {
1698 fn materialize(&mut self, node: &SeedNode) -> Result<Vec<u8>> {
1699 match node.kind {
1705 NodeKind::PackageOpcModel
1706 | NodeKind::DocxModel
1707 | NodeKind::DocxStory
1708 | NodeKind::EpubModel
1709 | NodeKind::EpubContent
1710 | NodeKind::OdtModel
1711 | NodeKind::OdtContent => {
1712 self.stats.xml_parses = self.stats.xml_parses.saturating_add(1);
1713 }
1714 _ => {}
1715 }
1716 let depth = node.limits.max_depth;
1717 if self.use_cache {
1718 if let Some((id, bytes)) = self.prefetched_output.take() {
1722 if id == node.content_id() {
1723 self.reuse.nodes_reused = self.reuse.nodes_reused.saturating_add(1);
1724 self.budget.charge_bytes(bytes.len() as u64)?;
1725 return Ok(bytes);
1726 }
1727 self.prefetched_output = Some((id, bytes));
1728 }
1729 dag::materialize_node_cached_with(
1730 self.source,
1731 &self.seeds,
1732 &mut *self.cache,
1733 node,
1734 self.limits,
1735 &mut self.budget,
1736 depth,
1737 &mut self.reuse,
1738 )
1739 } else {
1740 let mut cache = dag::NoCache;
1741 dag::materialize_node_cached_with(
1742 self.source,
1743 &self.seeds,
1744 &mut cache,
1745 node,
1746 self.limits,
1747 &mut self.budget,
1748 depth,
1749 &mut self.reuse,
1750 )
1751 }
1752 }
1753
1754 fn load(&self, id: &NodeId) -> Result<SeedNode> {
1755 dag::load_node(&self.seeds, id)
1756 }
1757
1758 fn lookup(&mut self, key: SelectorKey) -> Result<Vec<IndexEntry>> {
1759 if !self.manifest.has_index() {
1760 return Ok(Vec::new());
1761 }
1762 let prefetched = self.prefetched.get(&key).cloned();
1767 let entries = match prefetched {
1768 Some(entries) => entries,
1769 None => {
1770 let root = NodeId::from_bytes(self.manifest.index_root);
1771 lookup(self.istore, &root, &key)?
1772 }
1773 };
1774 self.stats.index_nodes_read += entries.len() as u64;
1775 Ok(entries)
1776 }
1777
1778 fn require_entry(&mut self, key: SelectorKey, what: &str) -> Result<IndexEntry> {
1779 let entries = self.lookup(key)?;
1780 entries.into_iter().next().ok_or_else(|| {
1781 Error::unsupported_feature(format!(
1782 "no {what} matching selector number {} in the observation index",
1783 key.number
1784 ))
1785 })
1786 }
1787
1788 fn dispatch(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1789 if req.selector.is_common() {
1792 return self.common_dispatch(req);
1793 }
1794 use Representation as R;
1795 match (&req.selector, req.representation) {
1796 (Selector::Document, R::FullDocument | R::ExactBytes) => self.document_full(req),
1797 (Selector::Document, R::Metadata) => self.document_metadata(req),
1798 (Selector::ByteRange { offset, len }, R::ExactBytes) => {
1799 self.byte_range(req, *offset, *len)
1800 }
1801 (Selector::Object(n), R::ExactBytes | R::EncodedBytes) => {
1802 self.indexed_exact(req, SelectorKey::new(SEL_OBJECT, *n), "object")
1803 }
1804 (Selector::Revision(n), R::ExactBytes) => {
1805 self.indexed_exact(req, SelectorKey::new(SEL_REVISION, *n), "revision")
1806 }
1807 (Selector::Member(n), R::EncodedBytes) => self.indexed_exact(
1808 req,
1809 SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, *n),
1810 "package member",
1811 ),
1812 (Selector::Member(n), R::DecodedBytes) => self.member_decoded(req, *n),
1813 (Selector::PackagePart(_), R::Metadata | R::ExactBytes | R::DecodedBytes) => {
1814 self.package_part_opc(req)
1815 }
1816 (Selector::Relationship(_), R::Metadata | R::ExactBytes | R::DecodedBytes) => {
1817 self.relationship_opc(req)
1818 }
1819 (Selector::Stream(n), R::EncodedBytes) => {
1820 self.indexed_exact(req, SelectorKey::new(SEL_STREAM, *n), "stream")
1821 }
1822 (Selector::Stream(n), R::DecodedBytes) => self.stream_decoded(req, *n),
1823 (Selector::Stream(n), R::Operators) => self.stream_operators(req, *n),
1824 (Selector::Page(n), R::Text) => self.page_text(req, *n),
1825 (Selector::Page(n), R::Preview) => self.page_preview(req, *n),
1826 (Selector::Page(n), R::Structure) => self.page_structure(req, *n),
1827 (Selector::TextMatch(p), R::Text) => self.text_match(req, p),
1828 #[cfg(feature = "docx")]
1829 (Selector::DocxStory { story, profile }, R::Text) => {
1830 self.docx_story_text(req, *story, profile)
1831 }
1832 #[cfg(feature = "docx")]
1833 (Selector::DocxStory { story, profile }, R::Structure) => {
1834 self.docx_story_structure(req, *story, profile)
1835 }
1836 #[cfg(feature = "docx")]
1837 (Selector::DocxStory { story, profile }, R::Metadata) => {
1838 self.docx_story_metadata(req, *story, profile)
1839 }
1840 #[cfg(feature = "docx")]
1841 (
1842 Selector::DocxParagraph {
1843 story,
1844 index,
1845 profile,
1846 },
1847 R::Text | R::Metadata,
1848 ) => self.docx_paragraph(req, *story, *index, profile),
1849 #[cfg(feature = "docx")]
1850 (
1851 Selector::DocxTable {
1852 story,
1853 index,
1854 profile,
1855 },
1856 R::Text | R::Metadata,
1857 ) => self.docx_table(req, *story, *index, profile),
1858 #[cfg(feature = "docx")]
1859 (
1860 Selector::DocxCell {
1861 story,
1862 table,
1863 cell,
1864 profile,
1865 },
1866 R::Text | R::Metadata,
1867 ) => self.docx_cell(req, *story, *table, cell, profile),
1868 #[cfg(feature = "docx")]
1869 (
1870 Selector::DocxFind {
1871 story,
1872 pattern,
1873 profile,
1874 },
1875 R::Text,
1876 ) => self.docx_find(req, *story, pattern, profile),
1877 #[cfg(feature = "epub")]
1878 (Selector::EpubPackage, R::Metadata | R::Structure) => self.epub_package(req),
1879 #[cfg(feature = "epub")]
1880 (Selector::EpubManifestItem { index }, R::Metadata) => {
1881 self.epub_manifest_item_meta(req, *index)
1882 }
1883 #[cfg(feature = "epub")]
1884 (Selector::EpubManifestItem { index }, R::ExactBytes | R::DecodedBytes) => {
1885 self.epub_manifest_item_bytes(req, *index)
1886 }
1887 #[cfg(feature = "epub")]
1888 (Selector::EpubSpineItem { index, profile }, R::Metadata) => {
1889 self.epub_spine_item_meta(req, *index, profile)
1890 }
1891 #[cfg(feature = "epub")]
1892 (Selector::EpubSpineItem { index, profile }, R::Text) => {
1893 self.epub_spine_item_text(req, *index, profile)
1894 }
1895 #[cfg(feature = "epub")]
1896 (Selector::EpubSpineItem { index, profile }, R::Structure) => {
1897 self.epub_spine_item_structure(req, *index, profile)
1898 }
1899 #[cfg(feature = "epub")]
1900 (Selector::EpubSpineItem { index, profile }, R::Preview) => {
1901 self.epub_spine_item_preview(req, *index, profile)
1902 }
1903 #[cfg(feature = "epub")]
1904 (Selector::EpubSpineItem { index, profile }, R::ExactBytes | R::DecodedBytes) => {
1905 self.epub_spine_item_bytes(req, *index, profile)
1906 }
1907 #[cfg(feature = "epub")]
1908 (Selector::EpubNav, R::Metadata | R::Structure) => self.epub_nav(req),
1909 #[cfg(feature = "epub")]
1910 (Selector::EpubNavNode { index }, R::Metadata) => self.epub_nav_node(req, *index),
1911 #[cfg(feature = "epub")]
1912 (Selector::EpubResource(name), R::Metadata) => self.epub_resource_meta(req, name),
1913 #[cfg(feature = "epub")]
1914 (Selector::EpubResource(name), R::ExactBytes | R::DecodedBytes) => {
1915 self.epub_resource_bytes(req, name)
1916 }
1917 #[cfg(feature = "epub")]
1918 (
1919 Selector::EpubBlock {
1920 index,
1921 block,
1922 profile,
1923 },
1924 R::Text | R::Metadata | R::Structure,
1925 ) => self.epub_block(req, *index, *block, profile),
1926 #[cfg(feature = "epub")]
1927 (
1928 Selector::EpubCell {
1929 index,
1930 table,
1931 row,
1932 col,
1933 profile,
1934 },
1935 R::Text | R::Metadata,
1936 ) => self.epub_cell(req, *index, *table, *row, *col, profile),
1937 #[cfg(feature = "epub")]
1938 (
1939 Selector::EpubLink {
1940 index,
1941 link,
1942 profile,
1943 },
1944 R::Metadata,
1945 ) => self.epub_link(req, *index, *link, profile),
1946 #[cfg(feature = "epub")]
1947 (
1948 Selector::EpubFind {
1949 index,
1950 pattern,
1951 profile,
1952 },
1953 R::Text,
1954 ) => self.epub_find(req, *index, pattern, profile),
1955 #[cfg(feature = "odt")]
1956 (Selector::OdtPart(_), R::Metadata | R::ExactBytes | R::DecodedBytes) => {
1957 self.odt_part(req)
1958 }
1959 #[cfg(feature = "odt")]
1960 (Selector::OdtParagraph { index, profile }, R::Text | R::Metadata) => {
1961 self.odt_paragraph(req, *index, profile)
1962 }
1963 #[cfg(feature = "odt")]
1964 (Selector::OdtHeading { index, profile }, R::Text | R::Metadata) => {
1965 self.odt_heading(req, *index, profile)
1966 }
1967 #[cfg(feature = "odt")]
1968 (Selector::OdtTable { index, profile }, R::Text | R::Metadata) => {
1969 self.odt_table(req, *index, profile)
1970 }
1971 #[cfg(feature = "odt")]
1972 (
1973 Selector::OdtCell {
1974 table,
1975 row,
1976 col,
1977 profile,
1978 },
1979 R::Text | R::Metadata,
1980 ) => self.odt_cell(req, *table, *row, *col, profile),
1981 #[cfg(feature = "odt")]
1982 (Selector::OdtList { index, profile }, R::Text | R::Metadata) => {
1983 self.odt_list(req, *index, profile)
1984 }
1985 #[cfg(feature = "odt")]
1986 (Selector::OdtFind { pattern, profile }, R::Text) => {
1987 self.odt_find(req, pattern, profile)
1988 }
1989 _ => Err(Error::unsupported_feature(format!(
1990 "unsupported observation: selector {} with representation {}",
1991 req.selector.canonical(),
1992 req.representation.name()
1993 ))),
1994 }
1995 }
1996
1997 fn document_full(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1998 let bytes = self.source.serve_document(self.limits)?;
1999 Ok(FieldAnswer {
2000 value: AnswerValue::Bytes(bytes),
2001 basis: Basis::DirectlyObserved,
2002 selector: req.selector.canonical(),
2003 representation: req.representation.name().to_string(),
2004 source_span: Some((0, self.manifest.source_len)),
2005 provenance: String::new(),
2006 dependency_ids: vec![self.manifest.root_node],
2007 integrity_scope: IntegrityScope::WholeSource,
2008 exact: true,
2009 })
2010 }
2011
2012 fn document_metadata(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
2013 let json = format!(
2014 concat!(
2015 "{{",
2016 "\"source_len\":{},",
2017 "\"source_sha256\":\"{}\",",
2018 "\"object_count\":{},",
2019 "\"graph_ops\":{},",
2020 "\"node_count\":{}",
2021 "}}"
2022 ),
2023 self.manifest.source_len,
2024 crate::integrity::to_hex(&self.manifest.source_sha256),
2025 self.object_count,
2026 self.graph_ops,
2027 self.manifest.node_count,
2028 );
2029 Ok(FieldAnswer {
2030 value: AnswerValue::Json(json),
2031 basis: Basis::DeterministicallyDerived,
2032 selector: req.selector.canonical(),
2033 representation: req.representation.name().to_string(),
2034 source_span: None,
2035 provenance: String::new(),
2036 dependency_ids: Vec::new(),
2037 integrity_scope: IntegrityScope::None,
2038 exact: false,
2039 })
2040 }
2041
2042 fn byte_range(&mut self, req: &ObserveRequest, offset: u64, len: u64) -> Result<FieldAnswer> {
2043 let end = offset
2044 .checked_add(len)
2045 .ok_or_else(|| Error::usage("byte-range end overflows"))?;
2046 let node = SeedNode::new(
2047 NodeKind::SourceSlice,
2048 len,
2049 span_params(offset, len),
2050 Vec::new(),
2051 "field:observe;source-slice",
2052 );
2053 let bytes = self.materialize(&node)?;
2054 Ok(FieldAnswer {
2055 value: AnswerValue::Bytes(bytes),
2056 basis: Basis::DirectlyObserved,
2057 selector: req.selector.canonical(),
2058 representation: req.representation.name().to_string(),
2059 source_span: Some((offset, end)),
2060 provenance: String::new(),
2061 dependency_ids: Vec::new(),
2062 integrity_scope: IntegrityScope::Node,
2063 exact: true,
2064 })
2065 }
2066
2067 fn indexed_exact(
2068 &mut self,
2069 req: &ObserveRequest,
2070 key: SelectorKey,
2071 what: &str,
2072 ) -> Result<FieldAnswer> {
2073 let entry = self.require_entry(key, what)?;
2074 let node = self.load(&entry.node_id)?;
2075 let bytes = self.materialize(&node)?;
2076 let end = entry.out_off.saturating_add(entry.out_len);
2077 Ok(FieldAnswer {
2078 value: AnswerValue::Bytes(bytes),
2079 basis: Basis::DirectlyObserved,
2080 selector: req.selector.canonical(),
2081 representation: req.representation.name().to_string(),
2082 source_span: Some((entry.out_off, end)),
2083 provenance: String::new(),
2084 dependency_ids: vec![entry.node_id],
2085 integrity_scope: IntegrityScope::Node,
2086 exact: true,
2087 })
2088 }
2089
2090 fn stream_decoded(&mut self, req: &ObserveRequest, object: u32) -> Result<FieldAnswer> {
2091 let entry = self.require_entry(SelectorKey::new(SEL_STREAM, object), "stream")?;
2092 let node = self.decoded_node(object, &entry.node_id)?;
2093 let id = node.content_id();
2094 let bytes = self.materialize(&node)?;
2095 Ok(FieldAnswer {
2096 value: AnswerValue::Bytes(bytes),
2097 basis: Basis::DeterministicallyDerived,
2098 selector: req.selector.canonical(),
2099 representation: req.representation.name().to_string(),
2100 source_span: None,
2101 provenance: String::new(),
2102 dependency_ids: vec![id],
2103 integrity_scope: IntegrityScope::None,
2104 exact: false,
2105 })
2106 }
2107
2108 fn member_decoded(&mut self, req: &ObserveRequest, ordinal: u32) -> Result<FieldAnswer> {
2115 let entry = self.require_entry(
2116 SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, ordinal),
2117 "decoded package member",
2118 )?;
2119 let node = self.load(&entry.node_id)?;
2120 self.stats.member_decodes = self.stats.member_decodes.saturating_add(1);
2121 let id = node.content_id();
2122 let raw_deps = node.deps.clone();
2123 let bytes = self.materialize(&node)?;
2124 let mut dependency_ids = vec![id];
2125 dependency_ids.extend(raw_deps);
2126 Ok(FieldAnswer {
2127 value: AnswerValue::Bytes(bytes),
2128 basis: Basis::DeterministicallyDerived,
2129 selector: req.selector.canonical(),
2130 representation: req.representation.name().to_string(),
2131 source_span: None,
2132 provenance: String::new(),
2133 dependency_ids,
2134 integrity_scope: IntegrityScope::None,
2135 exact: false,
2136 })
2137 }
2138
2139 #[cfg(feature = "opc")]
2141 fn opc_model(&mut self) -> Result<crate::adapter::package::opc::OpcModel> {
2142 let entry = self.require_entry(SelectorKey::new(SEL_OPC_MODEL, 0), "OPC model")?;
2143 let node = self.load(&entry.node_id)?;
2144 let bytes = self.materialize(&node)?;
2145 crate::adapter::package::opc::OpcModel::decode(&bytes)
2146 }
2147
2148 #[cfg(feature = "opc")]
2152 fn package_part_opc(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
2153 use Representation as R;
2154 let Selector::PackagePart(name) = &req.selector else {
2155 return Err(Error::internal_invariant(
2156 "package_part_opc needs PackagePart",
2157 ));
2158 };
2159 let name = name.clone();
2160 let model = self.opc_model()?;
2161 let part = model.part_by_name(&name).ok_or_else(|| {
2162 Error::invalid_package_structure(format!("no package part named {name:?}"))
2163 })?;
2164 let ordinal = part.ordinal;
2165 match req.representation {
2166 R::ExactBytes => self.indexed_exact(
2167 req,
2168 SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, ordinal),
2169 "package part",
2170 ),
2171 R::DecodedBytes => self.member_decoded(req, ordinal),
2172 R::Metadata => {
2173 let rel_count = model
2174 .part_rels
2175 .iter()
2176 .find(|(o, _)| *o == ordinal)
2177 .map_or(0, |(_, r)| r.len());
2178 let ct = match &part.content_type {
2179 Some(c) => format!("\"{}\"", json_escape(c)),
2180 None => "null".to_string(),
2181 };
2182 let json = format!(
2183 "{{\"name\":\"{}\",\"ordinal\":{},\"content_type\":{},\"relationships\":{}}}",
2184 json_escape(&part.name),
2185 ordinal,
2186 ct,
2187 rel_count
2188 );
2189 Ok(FieldAnswer {
2190 value: AnswerValue::Json(json),
2191 basis: Basis::DeterministicallyDerived,
2192 selector: req.selector.canonical(),
2193 representation: req.representation.name().to_string(),
2194 source_span: None,
2195 provenance: String::new(),
2196 dependency_ids: Vec::new(),
2197 integrity_scope: IntegrityScope::None,
2198 exact: false,
2199 })
2200 }
2201 _ => Err(Error::unsupported_feature(format!(
2202 "unsupported observation: selector {} with representation {}",
2203 req.selector.canonical(),
2204 req.representation.name()
2205 ))),
2206 }
2207 }
2208
2209 #[cfg(feature = "opc")]
2214 fn relationship_opc(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
2215 use Representation as R;
2216 let Selector::Relationship(id) = &req.selector else {
2217 return Err(Error::internal_invariant(
2218 "relationship_opc needs Relationship",
2219 ));
2220 };
2221 let id = id.clone();
2222 let model = self.opc_model()?;
2223 let (rel, owner) = model.relationship_by_id(&id)?.ok_or_else(|| {
2224 Error::invalid_package_structure(format!("no package relationship with id {id:?}"))
2225 })?;
2226 match req.representation {
2227 R::Metadata => {
2228 let resolved = match &rel.resolved {
2229 Some(r) => format!("\"{}\"", json_escape(r)),
2230 None => "null".to_string(),
2231 };
2232 let owner_json = match owner {
2233 Some(o) => o.to_string(),
2234 None => "null".to_string(),
2235 };
2236 let json = format!(
2237 concat!(
2238 "{{\"id\":\"{}\",\"type\":\"{}\",\"target\":\"{}\",",
2239 "\"target_mode\":\"{}\",\"resolved\":{},\"owner\":{}}}"
2240 ),
2241 json_escape(&rel.id),
2242 json_escape(&rel.rel_type),
2243 json_escape(&rel.target),
2244 rel.mode.name(),
2245 resolved,
2246 owner_json
2247 );
2248 Ok(FieldAnswer {
2249 value: AnswerValue::Json(json),
2250 basis: Basis::DeterministicallyDerived,
2251 selector: req.selector.canonical(),
2252 representation: req.representation.name().to_string(),
2253 source_span: None,
2254 provenance: String::new(),
2255 dependency_ids: Vec::new(),
2256 integrity_scope: IntegrityScope::None,
2257 exact: false,
2258 })
2259 }
2260 R::ExactBytes | R::DecodedBytes => {
2261 let resolved = rel.resolved.clone().ok_or_else(|| {
2262 Error::invalid_package_structure(format!(
2263 "relationship {id:?} is external: its target is an inert identifier, never fetched"
2264 ))
2265 })?;
2266 let part = model.part_by_name(&resolved).ok_or_else(|| {
2267 Error::invalid_package_structure(format!(
2268 "relationship {id:?} target {resolved:?} is not a package part"
2269 ))
2270 })?;
2271 let ordinal = part.ordinal;
2272 if req.representation == R::ExactBytes {
2273 self.indexed_exact(
2274 req,
2275 SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, ordinal),
2276 "relationship target part",
2277 )
2278 } else {
2279 self.member_decoded(req, ordinal)
2280 }
2281 }
2282 _ => Err(Error::unsupported_feature(format!(
2283 "unsupported observation: selector {} with representation {}",
2284 req.selector.canonical(),
2285 req.representation.name()
2286 ))),
2287 }
2288 }
2289
2290 #[cfg(not(feature = "opc"))]
2292 fn package_part_opc(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
2293 Err(Error::unsupported_feature(
2294 "OPC support is not compiled in (feature `opc`)",
2295 ))
2296 }
2297
2298 #[cfg(not(feature = "opc"))]
2300 fn relationship_opc(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
2301 Err(Error::unsupported_feature(
2302 "OPC support is not compiled in (feature `opc`)",
2303 ))
2304 }
2305
2306 fn stream_operators(&mut self, req: &ObserveRequest, object: u32) -> Result<FieldAnswer> {
2307 let entry = self.require_entry(SelectorKey::new(SEL_STREAM, object), "stream")?;
2308 let decoded = self.decoded_node(object, &entry.node_id)?;
2309 let decoded_id = decoded.content_id();
2310 let node = SeedNode::new(
2311 NodeKind::ContentOperators,
2312 0,
2313 Vec::new(),
2314 vec![decoded_id],
2315 "pdf:content-operators",
2316 );
2317 let id = node.content_id();
2318 let bytes = self.materialize(&node)?;
2319 Ok(FieldAnswer {
2320 value: AnswerValue::Bytes(bytes),
2321 basis: Basis::DeterministicallyDerived,
2322 selector: req.selector.canonical(),
2323 representation: req.representation.name().to_string(),
2324 source_span: None,
2325 provenance: String::new(),
2326 dependency_ids: vec![id, decoded_id],
2327 integrity_scope: IntegrityScope::None,
2328 exact: false,
2329 })
2330 }
2331
2332 fn page_content_id(&mut self, what_number: u32) -> Result<NodeId> {
2333 Ok(self
2334 .require_entry(SelectorKey::new(SEL_PAGE, what_number), "page")?
2335 .node_id)
2336 }
2337
2338 fn ensure_page_derived(
2346 &mut self,
2347 page: u32,
2348 page_content: NodeId,
2349 ) -> Result<(SeedNode, SeedNode, SeedNode)> {
2350 let (ops, text, preview) = derived_nodes(page, page_content);
2351 let present = self.seeds.contains_node(&ops.content_id())?
2352 && self.seeds.contains_node(&text.content_id())?
2353 && self.seeds.contains_node(&preview.content_id())?;
2354 if !present {
2355 let promoted = ingest::deepen_page_with_manifest(self.store, self.manifest, page)?;
2358 self.stats.deepened = true;
2359 self.current_id = promoted;
2360 }
2361 Ok((ops, text, preview))
2362 }
2363
2364 fn page_text(&mut self, req: &ObserveRequest, page: u32) -> Result<FieldAnswer> {
2365 let pc = self.page_content_id(page)?;
2366 let (ops, text, _preview) = self.ensure_page_derived(page, pc)?;
2367 let text_id = text.content_id();
2368 let bytes = self.materialize(&text)?;
2369 let value = AnswerValue::Text(String::from_utf8_lossy(&bytes).into_owned());
2370 Ok(FieldAnswer {
2371 value,
2372 basis: Basis::Heuristic,
2373 selector: req.selector.canonical(),
2374 representation: req.representation.name().to_string(),
2375 source_span: None,
2376 provenance: String::new(),
2377 dependency_ids: vec![text_id, ops.content_id(), pc],
2378 integrity_scope: IntegrityScope::None,
2379 exact: false,
2380 })
2381 }
2382
2383 fn page_preview(&mut self, req: &ObserveRequest, page: u32) -> Result<FieldAnswer> {
2384 let pc = self.page_content_id(page)?;
2385 let (_ops, _text, preview) = self.ensure_page_derived(page, pc)?;
2386 let preview_id = preview.content_id();
2387 let bytes = self.materialize(&preview)?;
2388 Ok(FieldAnswer {
2389 value: AnswerValue::Bytes(bytes),
2390 basis: Basis::Heuristic,
2391 selector: req.selector.canonical(),
2392 representation: req.representation.name().to_string(),
2393 source_span: None,
2394 provenance: String::new(),
2395 dependency_ids: vec![preview_id, pc],
2396 integrity_scope: IntegrityScope::None,
2397 exact: false,
2398 })
2399 }
2400
2401 fn page_structure(&mut self, req: &ObserveRequest, page: u32) -> Result<FieldAnswer> {
2404 let pc = self.page_content_id(page)?;
2405 let (_ops, _text, preview) = self.ensure_page_derived(page, pc)?;
2406 let preview_id = preview.content_id();
2407 let preview_bytes = self.materialize(&preview)?;
2408 let (text_bytes, draw_ops, path_ops) = preview_stats(&preview_bytes);
2409
2410 let pc_node = self.load(&pc)?;
2413 let mut streams: Vec<u32> = Vec::new();
2414 for dep in &pc_node.deps {
2415 let dep_node = self.load(dep)?;
2416 if !matches!(
2420 dep_node.kind,
2421 NodeKind::PdfStreamDecoded | NodeKind::PdfStreamEncoded
2422 ) {
2423 continue;
2424 }
2425 if let Ok(object) = read_u32_params(&dep_node.params) {
2426 streams.push(object);
2427 }
2428 }
2429 let streams_json = streams
2430 .iter()
2431 .map(u32::to_string)
2432 .collect::<Vec<_>>()
2433 .join(",");
2434
2435 let json = format!(
2436 "{{\"page\":{page},\"text_bytes\":{text_bytes},\"draw_ops\":{draw_ops},\"path_ops\":{path_ops},\"content_streams\":[{streams_json}]}}"
2437 );
2438 Ok(FieldAnswer {
2439 value: AnswerValue::Json(json),
2440 basis: Basis::DeterministicallyDerived,
2441 selector: req.selector.canonical(),
2442 representation: req.representation.name().to_string(),
2443 source_span: None,
2444 provenance: String::new(),
2445 dependency_ids: vec![preview_id, pc],
2446 integrity_scope: IntegrityScope::None,
2447 exact: false,
2448 })
2449 }
2450
2451 fn text_match(&mut self, req: &ObserveRequest, pattern: &str) -> Result<FieldAnswer> {
2452 let mut items: Vec<(u32, String)> = Vec::new();
2453 let mut estimated: u64 = 0;
2454 let mut page: u32 = 1;
2455 while page <= MAX_TEXTMATCH_PAGES {
2456 let entries = self.lookup(SelectorKey::new(SEL_PAGE, page))?;
2457 let Some(entry) = entries.into_iter().next() else {
2458 break;
2459 };
2460 let pc = entry.node_id;
2461 let (_ops, text, _preview) = self.ensure_page_derived(page, pc)?;
2462 let bytes = self.materialize(&text)?;
2463 let rendered = String::from_utf8_lossy(&bytes).into_owned();
2464 for line in rendered.split('\n') {
2465 if line.contains(pattern) {
2466 estimated = estimated.saturating_add(line.len() as u64 + 32);
2467 if estimated > req.budget.max_output_bytes {
2468 return Err(Error::resource_limit(format!(
2469 "text match exceeded the {}-byte budget",
2470 req.budget.max_output_bytes
2471 )));
2472 }
2473 items.push((page, line.to_string()));
2474 }
2475 }
2476 page += 1;
2477 }
2478 let body = items
2479 .iter()
2480 .map(|(p, line)| format!("{{\"page\":{p},\"line\":\"{}\"}}", json_escape(line)))
2481 .collect::<Vec<_>>()
2482 .join(",");
2483 Ok(FieldAnswer {
2484 value: AnswerValue::Json(format!("[{body}]")),
2485 basis: Basis::Heuristic,
2486 selector: req.selector.canonical(),
2487 representation: req.representation.name().to_string(),
2488 source_span: None,
2489 provenance: String::new(),
2490 dependency_ids: Vec::new(),
2491 integrity_scope: IntegrityScope::None,
2492 exact: false,
2493 })
2494 }
2495
2496 #[cfg(feature = "docx")]
2503 fn docx_model(&mut self) -> Result<Arc<DocxModel>> {
2504 let entry = self.require_entry(SelectorKey::new(SEL_DOCX_MODEL, 0), "DOCX model")?;
2505 if self.use_cache
2506 && let Some(m) = self.models.get_docx(&entry.node_id)
2507 {
2508 return Ok(m);
2509 }
2510 let node = self.load(&entry.node_id)?;
2511 let bytes = self.materialize(&node)?;
2512 let m = Arc::new(DocxModel::decode(&bytes)?);
2513 if self.use_cache {
2514 self.models
2515 .put_docx(entry.node_id, Arc::clone(&m), bytes.len() as u64);
2516 }
2517 Ok(m)
2518 }
2519
2520 #[cfg(feature = "docx")]
2524 fn docx_story_view(
2525 &mut self,
2526 story: DocxStory,
2527 profile: &DocxExtractProfile,
2528 ) -> Result<DocxStoryView> {
2529 if story.kind_index().is_none() {
2530 return Err(Error::unsupported_feature(format!(
2531 "DOCX story {} is declared but not part-backed; preserved exactly, not interpreted",
2532 story.name()
2533 )));
2534 }
2535 let model = self.docx_model()?;
2536 let part = model.story_part(story).cloned().ok_or_else(|| {
2537 Error::unsupported_feature(format!(
2538 "DOCX package has no part for story {}",
2539 story.name()
2540 ))
2541 })?;
2542 let dec = self.require_entry(
2543 SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, part.ordinal),
2544 "DOCX story part decoded bytes",
2545 )?;
2546 let mut deps = vec![dec.node_id];
2547 if let Some(styles) = &model.styles
2548 && let Ok(e) = self.require_entry(
2549 SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, styles.ordinal),
2550 "DOCX styles decoded bytes",
2551 )
2552 {
2553 deps.push(e.node_id);
2554 }
2555 self.stats.member_decodes = self.stats.member_decodes.saturating_add(deps.len() as u64);
2558 let span = self
2559 .lookup(SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, part.ordinal))?
2560 .into_iter()
2561 .next()
2562 .map(|e| (e.out_off, e.out_off.saturating_add(e.out_len)));
2563 let mut node = SeedNode::new(
2564 NodeKind::DocxStory,
2565 self.limits.max_output_bytes,
2566 story_params(story, &part.name, profile),
2567 deps.clone(),
2568 "docx:story",
2569 );
2570 node.limits.max_output_bytes = self.limits.max_output_bytes;
2571 let id = node.content_id();
2572 let cached = if self.use_cache {
2576 self.models.get_story(&id)
2577 } else {
2578 None
2579 };
2580 let sm = match cached {
2581 Some(m) => m,
2582 None => {
2583 let bytes = self.materialize(&node)?;
2584 let m = Arc::new(StoryModel::decode(&bytes)?);
2585 if self.use_cache {
2586 self.models
2587 .put_story(id, Arc::clone(&m), bytes.len() as u64);
2588 }
2589 m
2590 }
2591 };
2592 let mut ids = vec![id];
2593 ids.extend(deps);
2594 Ok(DocxStoryView {
2595 model: sm,
2596 part,
2597 deps: ids,
2598 span,
2599 })
2600 }
2601
2602 #[cfg(feature = "docx")]
2603 fn docx_answer(
2604 &self,
2605 req: &ObserveRequest,
2606 value: AnswerValue,
2607 provenance: String,
2608 span: Option<(u64, u64)>,
2609 deps: Vec<NodeId>,
2610 ) -> FieldAnswer {
2611 FieldAnswer {
2612 value,
2613 basis: Basis::DeterministicallyDerived,
2614 selector: req.selector.canonical(),
2615 representation: req.representation.name().to_string(),
2616 source_span: span,
2617 provenance,
2618 dependency_ids: deps,
2619 integrity_scope: IntegrityScope::None,
2620 exact: false,
2621 }
2622 }
2623
2624 #[cfg(feature = "docx")]
2625 fn docx_story_text(
2626 &mut self,
2627 req: &ObserveRequest,
2628 story: DocxStory,
2629 profile: &DocxExtractProfile,
2630 ) -> Result<FieldAnswer> {
2631 let v = self.docx_story_view(story, profile)?;
2632 let text = v.model.text();
2633 let provenance = format!(
2634 "docx;story={};part={};profile={}",
2635 story.name(),
2636 v.part.name,
2637 profile.fingerprint()
2638 );
2639 Ok(self.docx_answer(req, AnswerValue::Text(text), provenance, v.span, v.deps))
2640 }
2641
2642 #[cfg(feature = "docx")]
2643 fn docx_story_metadata(
2644 &mut self,
2645 req: &ObserveRequest,
2646 story: DocxStory,
2647 profile: &DocxExtractProfile,
2648 ) -> Result<FieldAnswer> {
2649 let v = self.docx_story_view(story, profile)?;
2650 let json = format!(
2651 concat!(
2652 "{{\"story\":\"{}\",\"part\":\"{}\",\"ordinal\":{},\"root\":\"{}\",",
2653 "\"paragraphs\":{},\"tables\":{},\"hyperlinks\":{},\"bookmarks\":{},",
2654 "\"resources\":{},\"sections\":{},\"profile\":\"{}\"}}"
2655 ),
2656 json_escape(&story.name()),
2657 json_escape(&v.part.name),
2658 v.part.ordinal,
2659 json_escape(&v.model.root_local),
2660 v.model.paragraphs().count(),
2661 v.model.tables().count(),
2662 v.model.hyperlinks.len(),
2663 v.model.bookmarks.len(),
2664 v.model.resources.len(),
2665 v.model.section_count,
2666 profile.fingerprint(),
2667 );
2668 let provenance = format!("docx;story={};part={}", story.name(), v.part.name);
2669 Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
2670 }
2671
2672 #[cfg(feature = "docx")]
2673 fn docx_story_structure(
2674 &mut self,
2675 req: &ObserveRequest,
2676 story: DocxStory,
2677 profile: &DocxExtractProfile,
2678 ) -> Result<FieldAnswer> {
2679 let v = self.docx_story_view(story, profile)?;
2680 let paras = v
2681 .model
2682 .paragraphs()
2683 .map(|p| {
2684 format!(
2685 "{{\"index\":{},\"heading\":{},\"style\":{},\"text_len\":{}}}",
2686 p.index,
2687 opt_u8_json(p.heading_level),
2688 opt_str_json(p.style_id.as_deref()),
2689 p.text.len()
2690 )
2691 })
2692 .collect::<Vec<_>>()
2693 .join(",");
2694 let tables = v
2695 .model
2696 .tables()
2697 .map(|t| {
2698 format!(
2699 "{{\"index\":{},\"rows\":{},\"cols_row0\":{}}}",
2700 t.index,
2701 t.rows.len(),
2702 t.rows.first().map_or(0, |r| r.cells.len())
2703 )
2704 })
2705 .collect::<Vec<_>>()
2706 .join(",");
2707 let json = format!(
2708 concat!(
2709 "{{\"story\":\"{}\",\"part\":\"{}\",\"blocks\":{},",
2710 "\"paragraphs\":[{}],\"tables\":[{}],\"profile\":\"{}\"}}"
2711 ),
2712 json_escape(&story.name()),
2713 json_escape(&v.part.name),
2714 v.model.blocks.len(),
2715 paras,
2716 tables,
2717 profile.fingerprint(),
2718 );
2719 Ok(self.docx_answer(
2720 req,
2721 AnswerValue::Json(json),
2722 format!("docx;story={};part={}", story.name(), v.part.name),
2723 v.span,
2724 v.deps,
2725 ))
2726 }
2727
2728 #[cfg(feature = "docx")]
2729 fn docx_paragraph(
2730 &mut self,
2731 req: &ObserveRequest,
2732 story: DocxStory,
2733 index: u32,
2734 profile: &DocxExtractProfile,
2735 ) -> Result<FieldAnswer> {
2736 let v = self.docx_story_view(story, profile)?;
2737 let p = v
2738 .model
2739 .paragraphs()
2740 .find(|p| p.index == index)
2741 .ok_or_else(|| {
2742 Error::unsupported_feature(format!(
2743 "DOCX story {} has no body paragraph {index}",
2744 story.name()
2745 ))
2746 })?;
2747 let text = p.text.clone();
2748 let style = p.style_id.clone();
2749 let heading = p.heading_level;
2750 let run_count = p.runs.len();
2751 let provenance = format!(
2752 "docx;story={};part={};paragraph={};profile={}",
2753 story.name(),
2754 v.part.name,
2755 index,
2756 profile.fingerprint()
2757 );
2758 let value = match req.representation {
2759 Representation::Text => AnswerValue::Text(text),
2760 Representation::Metadata => AnswerValue::Json(format!(
2761 concat!(
2762 "{{\"story\":\"{}\",\"part\":\"{}\",\"paragraph\":{},",
2763 "\"style\":{},\"heading\":{},\"runs\":{},\"text_len\":{}}}"
2764 ),
2765 json_escape(&story.name()),
2766 json_escape(&v.part.name),
2767 index,
2768 opt_str_json(style.as_deref()),
2769 opt_u8_json(heading),
2770 run_count,
2771 text.len(),
2772 )),
2773 _ => {
2774 return Err(Error::unsupported_feature(format!(
2775 "unsupported observation: selector {} with representation {}",
2776 req.selector.canonical(),
2777 req.representation.name()
2778 )));
2779 }
2780 };
2781 Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
2782 }
2783
2784 #[cfg(feature = "docx")]
2785 fn docx_table(
2786 &mut self,
2787 req: &ObserveRequest,
2788 story: DocxStory,
2789 index: u32,
2790 profile: &DocxExtractProfile,
2791 ) -> Result<FieldAnswer> {
2792 let v = self.docx_story_view(story, profile)?;
2793 let t = v.model.tables().find(|t| t.index == index).ok_or_else(|| {
2794 Error::unsupported_feature(format!("DOCX story {} has no table {index}", story.name()))
2795 })?;
2796 let text = t.text();
2797 let rows = t.rows.len();
2798 let cells: Vec<usize> = t.rows.iter().map(|r| r.cells.len()).collect();
2799 let provenance = format!(
2800 "docx;story={};part={};table={};profile={}",
2801 story.name(),
2802 v.part.name,
2803 index,
2804 profile.fingerprint()
2805 );
2806 let value = match req.representation {
2807 Representation::Text => AnswerValue::Text(text),
2808 Representation::Metadata => {
2809 let dims = cells
2810 .iter()
2811 .map(|c| c.to_string())
2812 .collect::<Vec<_>>()
2813 .join(",");
2814 AnswerValue::Json(format!(
2815 concat!(
2816 "{{\"story\":\"{}\",\"part\":\"{}\",\"table\":{},",
2817 "\"rows\":{},\"cells_per_row\":[{}],\"profile\":\"{}\"}}"
2818 ),
2819 json_escape(&story.name()),
2820 json_escape(&v.part.name),
2821 index,
2822 rows,
2823 dims,
2824 profile.fingerprint(),
2825 ))
2826 }
2827 _ => {
2828 return Err(Error::unsupported_feature(format!(
2829 "unsupported observation: selector {} with representation {}",
2830 req.selector.canonical(),
2831 req.representation.name()
2832 )));
2833 }
2834 };
2835 Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
2836 }
2837
2838 #[cfg(feature = "docx")]
2839 fn docx_cell(
2840 &mut self,
2841 req: &ObserveRequest,
2842 story: DocxStory,
2843 table: u32,
2844 cell: &str,
2845 profile: &DocxExtractProfile,
2846 ) -> Result<FieldAnswer> {
2847 let (col, row_idx) = parse_cell_ref(cell).ok_or_else(|| {
2848 Error::usage(format!("cell reference {cell:?} is not A1-style (e.g. B7)"))
2849 })?;
2850 let v = self.docx_story_view(story, profile)?;
2851 let t = v.model.tables().find(|t| t.index == table).ok_or_else(|| {
2852 Error::unsupported_feature(format!("DOCX story {} has no table {table}", story.name()))
2853 })?;
2854 let r = t.rows.get(row_idx as usize).ok_or_else(|| {
2855 Error::unsupported_feature(format!("DOCX table {table} has no row {}", row_idx + 1))
2856 })?;
2857 let found = r
2858 .cells
2859 .iter()
2860 .find(|c| col >= c.grid_col && col < c.grid_col.saturating_add(c.grid_span))
2861 .ok_or_else(|| {
2862 Error::unsupported_feature(format!(
2863 "DOCX table {table} row {} has no cell {cell}",
2864 row_idx + 1
2865 ))
2866 })?;
2867 let text = found.text.clone();
2868 let grid_col = found.grid_col;
2869 let grid_span = found.grid_span;
2870 let vmerge = found.vmerge_continue;
2871 let provenance = format!(
2872 "docx;story={};part={};table={};row={};cell={};profile={}",
2873 story.name(),
2874 v.part.name,
2875 table,
2876 row_idx + 1,
2877 cell,
2878 profile.fingerprint()
2879 );
2880 let value = match req.representation {
2881 Representation::Text => AnswerValue::Text(text),
2882 Representation::Metadata => AnswerValue::Json(format!(
2883 concat!(
2884 "{{\"story\":\"{}\",\"part\":\"{}\",\"table\":{},",
2885 "\"row\":{},\"cell\":\"{}\",\"grid_col\":{},\"grid_span\":{},",
2886 "\"vmerge_continue\":{},\"text_len\":{},\"profile\":\"{}\"}}"
2887 ),
2888 json_escape(&story.name()),
2889 json_escape(&v.part.name),
2890 table,
2891 row_idx + 1,
2892 json_escape(cell),
2893 grid_col,
2894 grid_span,
2895 vmerge,
2896 text.len(),
2897 profile.fingerprint(),
2898 )),
2899 _ => {
2900 return Err(Error::unsupported_feature(format!(
2901 "unsupported observation: selector {} with representation {}",
2902 req.selector.canonical(),
2903 req.representation.name()
2904 )));
2905 }
2906 };
2907 Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
2908 }
2909
2910 #[cfg(feature = "docx")]
2911 fn docx_find(
2912 &mut self,
2913 req: &ObserveRequest,
2914 story: DocxStory,
2915 pattern: &str,
2916 profile: &DocxExtractProfile,
2917 ) -> Result<FieldAnswer> {
2918 let v = self.docx_story_view(story, profile)?;
2919 let mut items: Vec<String> = Vec::new();
2920 let mut estimated: u64 = 0;
2921 for p in v.model.paragraphs() {
2922 if p.text.contains(pattern) {
2923 estimated = estimated.saturating_add(p.text.len() as u64 + 48);
2924 if estimated > req.budget.max_output_bytes {
2925 return Err(Error::resource_limit(format!(
2926 "DOCX find exceeded the {}-byte budget",
2927 req.budget.max_output_bytes
2928 )));
2929 }
2930 items.push(format!(
2931 "{{\"paragraph\":{},\"text\":\"{}\"}}",
2932 p.index,
2933 json_escape(&p.text)
2934 ));
2935 }
2936 }
2937 let provenance = format!(
2938 "docx;story={};part={};profile={}",
2939 story.name(),
2940 v.part.name,
2941 profile.fingerprint()
2942 );
2943 Ok(self.docx_answer(
2944 req,
2945 AnswerValue::Json(format!("[{}]", items.join(","))),
2946 provenance,
2947 v.span,
2948 v.deps,
2949 ))
2950 }
2951
2952 fn decoded_node(&mut self, object: u32, encoded_id: &NodeId) -> Result<SeedNode> {
2961 let entries = self.lookup(SelectorKey::new(SEL_STREAM_DECODED, object))?;
2962 if let Some(entry) = entries.into_iter().next() {
2963 return self.load(&entry.node_id);
2964 }
2965 self.deepen_stream(object, encoded_id)
2966 }
2967
2968 fn deepen_stream(&mut self, object: u32, encoded_id: &NodeId) -> Result<SeedNode> {
2971 let encoded_node = self.load(encoded_id)?;
2972 let encoded = self.materialize(&encoded_node)?;
2973 let cap = usize::try_from(self.limits.max_output_bytes).unwrap_or(usize::MAX);
2974 let decoded = super::inflate::inflate_bounded(&encoded, cap, super::inflate::Wrapper::Zlib)
2975 .map_err(|e| {
2976 Error::unsupported_feature(format!(
2977 "stream {object} has no recovered decoded representation: {e}"
2978 ))
2979 })?;
2980 let node = SeedNode::new(
2981 NodeKind::PdfStreamDecoded,
2982 decoded.len() as u64,
2983 u32_params(object),
2984 vec![*encoded_id],
2985 "pdf:stream-decoded",
2986 );
2987 self.store.seeds_mut().put_node(&node.encode_canonical())?;
2988 self.stats.deepened = true;
2989 Ok(node)
2990 }
2991}
2992
2993#[cfg(feature = "epub")]
2994fn epub_opt_str(v: Option<&str>) -> String {
2995 match v {
2996 Some(s) => format!("\"{}\"", json_escape(s)),
2997 None => "null".to_string(),
2998 }
2999}
3000
3001#[cfg(feature = "epub")]
3002fn epub_opt_u32(v: Option<u32>) -> String {
3003 match v {
3004 Some(n) => n.to_string(),
3005 None => "null".to_string(),
3006 }
3007}
3008
3009#[cfg(feature = "epub")]
3010fn epub_str_array(items: &[String]) -> String {
3011 format!(
3012 "[{}]",
3013 items
3014 .iter()
3015 .map(|s| format!("\"{}\"", json_escape(s)))
3016 .collect::<Vec<_>>()
3017 .join(",")
3018 )
3019}
3020
3021#[cfg(feature = "epub")]
3022fn epub_dir_of(name: &str) -> String {
3023 match name.rfind('/') {
3024 Some(i) => name[..=i].to_string(),
3025 None => String::new(),
3026 }
3027}
3028
3029#[cfg(feature = "epub")]
3030fn epub_mimetype_json(m: &crate::adapter::epub::MimetypeFacts) -> String {
3031 format!(
3032 "{{\"present\":{},\"first\":{},\"stored\":{},\"no_extra\":{},\"exact_bytes\":{},\"conformant\":{}}}",
3033 m.present, m.first, m.stored, m.no_extra, m.exact_bytes, m.conformant
3034 )
3035}
3036
3037#[cfg(feature = "epub")]
3038fn epub_rootfile_json(r: &crate::adapter::epub::RootFile) -> String {
3039 let ord = if r.ordinal == u32::MAX {
3040 None
3041 } else {
3042 Some(r.ordinal)
3043 };
3044 format!(
3045 "{{\"full_path\":\"{}\",\"member\":\"{}\",\"media_type\":\"{}\",\"ordinal\":{}}}",
3046 json_escape(&r.full_path),
3047 json_escape(&r.member),
3048 json_escape(&r.media_type),
3049 epub_opt_u32(ord)
3050 )
3051}
3052
3053#[cfg(feature = "epub")]
3054fn epub_meta_json(e: &crate::adapter::epub::MetadataEntry) -> String {
3055 format!(
3056 "{{\"name\":\"{}\",\"property\":{},\"refines\":{},\"id\":{},\"scheme\":{},\"value\":\"{}\"}}",
3057 json_escape(&e.name),
3058 epub_opt_str(e.property.as_deref()),
3059 epub_opt_str(e.refines.as_deref()),
3060 epub_opt_str(e.id.as_deref()),
3061 epub_opt_str(e.scheme.as_deref()),
3062 json_escape(&e.value)
3063 )
3064}
3065
3066#[cfg(feature = "epub")]
3067fn epub_manifest_json(it: &crate::adapter::epub::ManifestItem) -> String {
3068 format!(
3069 "{{\"id\":\"{}\",\"href\":\"{}\",\"media_type\":\"{}\",\"properties\":{},\"fallback\":{},\"resolved\":{},\"ordinal\":{},\"external\":{}}}",
3070 json_escape(&it.id),
3071 json_escape(&it.href),
3072 json_escape(&it.media_type),
3073 epub_str_array(&it.properties),
3074 epub_opt_str(it.fallback.as_deref()),
3075 epub_opt_str(it.resolved.as_deref()),
3076 epub_opt_u32(it.resolved_ordinal()),
3077 it.external
3078 )
3079}
3080
3081#[cfg(feature = "epub")]
3082fn epub_spine_json(s: &crate::adapter::epub::SpineItemRef) -> String {
3083 let index = if s.item_index == u32::MAX {
3084 None
3085 } else {
3086 Some(s.item_index)
3087 };
3088 let ord = if s.ordinal == u32::MAX {
3089 None
3090 } else {
3091 Some(s.ordinal)
3092 };
3093 format!(
3094 "{{\"idref\":\"{}\",\"linear\":{},\"properties\":{},\"item_index\":{},\"ordinal\":{}}}",
3095 json_escape(&s.idref),
3096 s.linear,
3097 epub_str_array(&s.properties),
3098 epub_opt_u32(index),
3099 epub_opt_u32(ord)
3100 )
3101}
3102
3103#[cfg(feature = "epub")]
3104fn epub_nav_json(index: u32, e: &crate::adapter::epub::NavEntry) -> String {
3105 format!(
3106 "{{\"index\":{},\"depth\":{},\"nav\":\"{}\",\"label\":\"{}\",\"href\":\"{}\",\"member\":{},\"fragment\":{},\"external\":{}}}",
3107 index,
3108 e.depth,
3109 json_escape(&e.nav_type),
3110 json_escape(&e.label),
3111 json_escape(&e.href),
3112 epub_opt_str(e.member.as_deref()),
3113 epub_opt_str(e.fragment.as_deref()),
3114 e.external
3115 )
3116}
3117
3118#[cfg(feature = "epub")]
3119fn epub_block_json(index: u32, b: &crate::adapter::epub::Block) -> String {
3120 use crate::adapter::epub::Block;
3121 match b {
3122 Block::Heading {
3123 level,
3124 id,
3125 epub_type,
3126 text,
3127 } => format!(
3128 "{{\"index\":{index},\"kind\":\"heading\",\"level\":{level},\"id\":{},\"epub_type\":{},\"text\":\"{}\"}}",
3129 epub_opt_str(id.as_deref()),
3130 epub_opt_str(epub_type.as_deref()),
3131 json_escape(text)
3132 ),
3133 Block::Paragraph { text } => format!(
3134 "{{\"index\":{index},\"kind\":\"paragraph\",\"text\":\"{}\"}}",
3135 json_escape(text)
3136 ),
3137 Block::List { ordered, items } => format!(
3138 "{{\"index\":{index},\"kind\":\"list\",\"ordered\":{ordered},\"items\":{}}}",
3139 epub_str_array(items)
3140 ),
3141 Block::Table { rows } => format!(
3142 "{{\"index\":{index},\"kind\":\"table\",\"rows\":{},\"cols\":{}}}",
3143 rows.len(),
3144 rows.first().map_or(0, |r| r.cells.len())
3145 ),
3146 }
3147}
3148
3149#[cfg(feature = "epub")]
3150fn epub_cell_json(table: u32, row: u32, col: u32, c: &crate::adapter::epub::Cell) -> String {
3151 format!(
3152 "{{\"table\":{table},\"row\":{row},\"col\":{col},\"header\":{},\"colspan\":{},\"rowspan\":{},\"text_len\":{}}}",
3153 c.header,
3154 c.colspan,
3155 c.rowspan,
3156 c.text.len()
3157 )
3158}
3159
3160#[cfg(feature = "epub")]
3161fn epub_link_json(index: u32, l: &crate::adapter::epub::Link) -> String {
3162 format!(
3163 "{{\"index\":{index},\"href\":\"{}\",\"text\":\"{}\",\"fragment\":{},\"member\":{},\"external\":{},\"epub_type\":{}}}",
3164 json_escape(&l.href),
3165 json_escape(&l.text),
3166 epub_opt_str(l.fragment.as_deref()),
3167 epub_opt_str(l.member.as_deref()),
3168 l.external,
3169 epub_opt_str(l.epub_type.as_deref())
3170 )
3171}
3172
3173#[cfg(feature = "epub")]
3174fn epub_resource_json(index: usize, r: &crate::adapter::epub::Resource) -> String {
3175 format!(
3176 "{{\"index\":{index},\"kind\":\"{}\",\"attr\":\"{}\",\"value\":\"{}\",\"member\":{},\"external\":{}}}",
3177 json_escape(&r.kind),
3178 json_escape(&r.attr),
3179 json_escape(&r.value),
3180 epub_opt_str(r.member.as_deref()),
3181 r.external
3182 )
3183}
3184
3185#[cfg(feature = "epub")]
3186fn epub_section_json(index: usize, s: &crate::adapter::epub::Section) -> String {
3187 format!(
3188 "{{\"index\":{index},\"local\":\"{}\",\"epub_type\":{},\"depth\":{}}}",
3189 json_escape(&s.local),
3190 epub_opt_str(s.epub_type.as_deref()),
3191 s.depth
3192 )
3193}
3194
3195#[cfg(feature = "epub")]
3196fn epub_content_structure_json(
3197 index: u32,
3198 model: &crate::adapter::epub::ContentModel,
3199 item: &ManifestItem,
3200 profile: &EpubExtractProfile,
3201) -> String {
3202 let blocks = model
3203 .blocks
3204 .iter()
3205 .enumerate()
3206 .map(|(i, b)| epub_block_json(i as u32, b))
3207 .collect::<Vec<_>>()
3208 .join(",");
3209 let headings = model
3210 .blocks
3211 .iter()
3212 .enumerate()
3213 .filter(|(_, b)| matches!(b, crate::adapter::epub::Block::Heading { .. }))
3214 .map(|(i, b)| epub_block_json(i as u32, b))
3215 .collect::<Vec<_>>()
3216 .join(",");
3217 let links = model
3218 .links
3219 .iter()
3220 .enumerate()
3221 .map(|(i, l)| epub_link_json(i as u32, l))
3222 .collect::<Vec<_>>()
3223 .join(",");
3224 let resources = model
3225 .resources
3226 .iter()
3227 .enumerate()
3228 .map(|(i, r)| epub_resource_json(i, r))
3229 .collect::<Vec<_>>()
3230 .join(",");
3231 let sections = model
3232 .sections
3233 .iter()
3234 .enumerate()
3235 .map(|(i, s)| epub_section_json(i, s))
3236 .collect::<Vec<_>>()
3237 .join(",");
3238 format!(
3239 concat!(
3240 "{{\"spine\":{},\"part\":\"{}\",\"profile\":\"{}\",\"root\":\"{}\",",
3241 "\"body\":{},\"scripted\":{},\"xhtml_nodes\":{},",
3242 "\"blocks\":[{}],\"headings\":[{}],\"links\":[{}],",
3243 "\"resources\":[{}],\"fragments\":{},\"sections\":[{}]}}"
3244 ),
3245 index,
3246 json_escape(item.resolved.as_deref().unwrap_or("")),
3247 profile.fingerprint(),
3248 json_escape(&model.root_local),
3249 model.body_seen,
3250 model.scripted,
3251 model.xhtml_nodes,
3252 blocks,
3253 headings,
3254 links,
3255 resources,
3256 epub_str_array(&model.fragments),
3257 sections
3258 )
3259}
3260
3261#[cfg(feature = "epub")]
3262type EpubContentView = (
3263 crate::adapter::epub::ContentModel,
3264 ManifestItem,
3265 Option<(u64, u64)>,
3266 Vec<NodeId>,
3267);
3268
3269#[cfg(feature = "epub")]
3270impl<S: SeedStore> Ctx<'_, S> {
3271 fn epub_model(&mut self) -> Result<EpubModel> {
3272 let entry = self.require_entry(SelectorKey::new(SEL_EPUB_MODEL, 0), "EPUB model")?;
3273 let node = self.load(&entry.node_id)?;
3274 let bytes = self.materialize(&node)?;
3275 EpubModel::decode(&bytes)
3276 }
3277
3278 fn epub_package_doc(&mut self) -> Result<PackageDoc> {
3279 let model = self.epub_model()?;
3280 model.package.ok_or_else(|| {
3281 Error::invalid_package_structure("EPUB container has no resolvable package document")
3282 })
3283 }
3284
3285 fn epub_member_decoded_bytes(&mut self, ordinal: u32) -> Result<Vec<u8>> {
3286 let entry = self.require_entry(
3287 SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, ordinal),
3288 "EPUB resource decoded bytes",
3289 )?;
3290 let node = self.load(&entry.node_id)?;
3291 self.stats.member_decodes = self.stats.member_decodes.saturating_add(1);
3292 self.materialize(&node)
3293 }
3294
3295 fn epub_member_span(&mut self, ordinal: Option<u32>) -> Option<(u64, u64)> {
3296 let o = ordinal?;
3297 self.lookup(SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, o))
3298 .ok()?
3299 .into_iter()
3300 .next()
3301 .map(|e| (e.out_off, e.out_off.saturating_add(e.out_len)))
3302 }
3303
3304 fn epub_answer(
3305 &self,
3306 req: &ObserveRequest,
3307 value: AnswerValue,
3308 provenance: String,
3309 span: Option<(u64, u64)>,
3310 deps: Vec<NodeId>,
3311 ) -> FieldAnswer {
3312 FieldAnswer {
3313 value,
3314 basis: Basis::DeterministicallyDerived,
3315 selector: req.selector.canonical(),
3316 representation: req.representation.name().to_string(),
3317 source_span: span,
3318 provenance,
3319 dependency_ids: deps,
3320 integrity_scope: IntegrityScope::None,
3321 exact: false,
3322 }
3323 }
3324
3325 fn epub_package(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3326 let model = self.epub_model()?;
3327 let doc = model.package.as_ref().ok_or_else(|| {
3328 Error::invalid_package_structure("EPUB container has no resolvable package document")
3329 })?;
3330 let rootfiles = model
3331 .rootfiles
3332 .iter()
3333 .map(epub_rootfile_json)
3334 .collect::<Vec<_>>()
3335 .join(",");
3336 let mut s = String::new();
3337 s.push_str("{\"package\":\"");
3338 s.push_str(&json_escape(&doc.member));
3339 s.push_str("\",\"version\":");
3340 s.push_str(&epub_opt_str(doc.version.as_deref()));
3341 s.push_str(",\"unique_identifier\":");
3342 s.push_str(&epub_opt_str(doc.unique_identifier.as_deref()));
3343 s.push_str(",\"page_progression_direction\":");
3344 s.push_str(&epub_opt_str(doc.page_progression.as_deref()));
3345 s.push_str(",\"rendition_layout\":");
3346 s.push_str(&epub_opt_str(doc.layout.as_deref()));
3347 s.push_str(",\"cover_id\":");
3348 s.push_str(&epub_opt_str(doc.cover_id.as_deref()));
3349 s.push_str(",\"nav_item\":");
3350 s.push_str(&epub_opt_u32(doc.nav_item));
3351 s.push_str(",\"ncx_item\":");
3352 s.push_str(&epub_opt_u32(doc.ncx_item));
3353 s.push_str(&format!(
3354 ",\"manifest_items\":{},\"spine_items\":{},\"metadata_entries\":{}",
3355 doc.manifest.len(),
3356 doc.spine.len(),
3357 doc.metadata.len()
3358 ));
3359 s.push_str(",\"rootfiles\":[");
3360 s.push_str(&rootfiles);
3361 s.push_str("],\"mimetype\":");
3362 s.push_str(&epub_mimetype_json(&model.mimetype));
3363 if req.representation == Representation::Structure {
3364 let man = doc
3365 .manifest
3366 .iter()
3367 .map(epub_manifest_json)
3368 .collect::<Vec<_>>()
3369 .join(",");
3370 let sp = doc
3371 .spine
3372 .iter()
3373 .map(epub_spine_json)
3374 .collect::<Vec<_>>()
3375 .join(",");
3376 let md = doc
3377 .metadata
3378 .iter()
3379 .map(epub_meta_json)
3380 .collect::<Vec<_>>()
3381 .join(",");
3382 s.push_str(",\"manifest\":[");
3383 s.push_str(&man);
3384 s.push_str("],\"spine\":[");
3385 s.push_str(&sp);
3386 s.push_str("],\"metadata\":[");
3387 s.push_str(&md);
3388 s.push(']');
3389 }
3390 s.push_str(",\"issues\":");
3391 s.push_str(&epub_str_array(&doc.issues));
3392 s.push('}');
3393 let provenance = format!("epub;package={}", doc.member);
3394 Ok(self.epub_answer(req, AnswerValue::Json(s), provenance, None, Vec::new()))
3395 }
3396
3397 fn epub_manifest_item_meta(&mut self, req: &ObserveRequest, index: u32) -> Result<FieldAnswer> {
3398 let doc = self.epub_package_doc()?;
3399 let item = doc
3400 .manifest
3401 .get(index as usize)
3402 .ok_or_else(|| Error::unsupported_feature(format!("no EPUB manifest item {index}")))?;
3403 let json = epub_manifest_json(item);
3404 let provenance = format!("epub;manifest={index};id={}", item.id);
3405 let span = self.epub_member_span(item.resolved_ordinal());
3406 Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, Vec::new()))
3407 }
3408
3409 fn epub_manifest_item_bytes(
3410 &mut self,
3411 req: &ObserveRequest,
3412 index: u32,
3413 ) -> Result<FieldAnswer> {
3414 let doc = self.epub_package_doc()?;
3415 let item =
3416 doc.manifest.get(index as usize).cloned().ok_or_else(|| {
3417 Error::unsupported_feature(format!("no EPUB manifest item {index}"))
3418 })?;
3419 self.epub_item_bytes(req, &item)
3420 }
3421
3422 fn epub_item_bytes(
3423 &mut self,
3424 req: &ObserveRequest,
3425 item: &ManifestItem,
3426 ) -> Result<FieldAnswer> {
3427 if item.external {
3428 return Err(Error::invalid_package_structure(format!(
3429 "EPUB manifest item {:?} is an external target: inert, never fetched",
3430 item.id
3431 )));
3432 }
3433 let ordinal = item.resolved_ordinal().ok_or_else(|| {
3434 Error::invalid_package_structure(format!(
3435 "EPUB manifest item {:?} has no resolvable container member",
3436 item.id
3437 ))
3438 })?;
3439 if req.representation == Representation::ExactBytes {
3440 self.indexed_exact(
3441 req,
3442 SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, ordinal),
3443 "EPUB resource",
3444 )
3445 } else {
3446 self.member_decoded(req, ordinal)
3447 }
3448 }
3449
3450 fn epub_spine_item_meta(
3451 &mut self,
3452 req: &ObserveRequest,
3453 index: u32,
3454 profile: &EpubExtractProfile,
3455 ) -> Result<FieldAnswer> {
3456 let doc = self.epub_package_doc()?;
3457 let order = doc.reading_order(profile);
3458 let mi = *order.get(index as usize).ok_or_else(|| {
3459 Error::unsupported_feature(format!(
3460 "no EPUB spine item {index} under profile {}",
3461 profile.fingerprint()
3462 ))
3463 })?;
3464 if mi == u32::MAX {
3465 return Err(Error::invalid_package_structure(
3466 "EPUB spine item does not resolve to a manifest item",
3467 ));
3468 }
3469 let item = doc.manifest.get(mi as usize).ok_or_else(|| {
3470 Error::invalid_package_structure("EPUB spine item manifest index is out of range")
3471 })?;
3472 let spine_ref = doc.spine.iter().find(|s| s.item_index == mi);
3473 let json = match spine_ref {
3474 Some(s) => format!(
3475 "{{\"index\":{},\"profile\":\"{}\",\"idref\":\"{}\",\"linear\":{},\"properties\":{},\"item\":{}}}",
3476 index,
3477 profile.fingerprint(),
3478 json_escape(&s.idref),
3479 s.linear,
3480 epub_str_array(&s.properties),
3481 epub_manifest_json(item)
3482 ),
3483 None => format!(
3484 "{{\"index\":{},\"profile\":\"{}\",\"idref\":null,\"item\":{}}}",
3485 index,
3486 profile.fingerprint(),
3487 epub_manifest_json(item)
3488 ),
3489 };
3490 let provenance = format!(
3491 "epub;spine={index};profile={};id={};part={}",
3492 profile.fingerprint(),
3493 item.id,
3494 item.resolved.as_deref().unwrap_or("")
3495 );
3496 let span = self.epub_member_span(item.resolved_ordinal());
3497 Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, Vec::new()))
3498 }
3499
3500 fn epub_spine_item_bytes(
3501 &mut self,
3502 req: &ObserveRequest,
3503 index: u32,
3504 profile: &EpubExtractProfile,
3505 ) -> Result<FieldAnswer> {
3506 let doc = self.epub_package_doc()?;
3507 let order = doc.reading_order(profile);
3508 let mi = *order.get(index as usize).ok_or_else(|| {
3509 Error::unsupported_feature(format!(
3510 "no EPUB spine item {index} under profile {}",
3511 profile.fingerprint()
3512 ))
3513 })?;
3514 let item = doc.manifest.get(mi as usize).cloned().ok_or_else(|| {
3515 Error::invalid_package_structure("EPUB spine item manifest index is out of range")
3516 })?;
3517 self.epub_item_bytes(req, &item)
3518 }
3519
3520 fn epub_content_view(
3525 &mut self,
3526 index: u32,
3527 profile: &EpubExtractProfile,
3528 ) -> Result<EpubContentView> {
3529 let doc = self.epub_package_doc()?;
3530 let order = doc.reading_order(profile);
3531 let mi = *order.get(index as usize).ok_or_else(|| {
3532 Error::unsupported_feature(format!(
3533 "no EPUB spine item {index} under profile {}",
3534 profile.fingerprint()
3535 ))
3536 })?;
3537 let item = doc.manifest.get(mi as usize).cloned().ok_or_else(|| {
3538 Error::invalid_package_structure("EPUB spine item manifest index is out of range")
3539 })?;
3540 let ordinal = item.resolved_ordinal().ok_or_else(|| {
3541 Error::invalid_package_structure(format!(
3542 "EPUB spine item {:?} has no resolvable container member",
3543 item.id
3544 ))
3545 })?;
3546 let dec = self.require_entry(
3547 SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, ordinal),
3548 "EPUB spine content decoded bytes",
3549 )?;
3550 self.stats.member_decodes = self.stats.member_decodes.saturating_add(1);
3551 let base_dir = epub_dir_of(item.resolved.as_deref().unwrap_or(""));
3552 let mut node = SeedNode::new(
3553 NodeKind::EpubContent,
3554 self.limits.max_output_bytes,
3555 crate::adapter::epub::content_params(index, ordinal, &base_dir, profile),
3556 vec![dec.node_id],
3557 "epub:content",
3558 );
3559 node.limits.max_output_bytes = self.limits.max_output_bytes;
3560 let id = node.content_id();
3561 let bytes = self.materialize(&node)?;
3562 let model = crate::adapter::epub::ContentModel::decode(&bytes)?;
3563 let span = self.epub_member_span(Some(ordinal));
3564 Ok((model, item, span, vec![id, dec.node_id]))
3565 }
3566
3567 fn epub_spine_item_text(
3568 &mut self,
3569 req: &ObserveRequest,
3570 index: u32,
3571 profile: &EpubExtractProfile,
3572 ) -> Result<FieldAnswer> {
3573 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3574 let provenance = format!(
3575 "epub;spine={index};part={};profile={};content",
3576 item.resolved.as_deref().unwrap_or(""),
3577 profile.fingerprint()
3578 );
3579 Ok(self.epub_answer(req, AnswerValue::Text(model.text()), provenance, span, deps))
3580 }
3581
3582 fn epub_spine_item_structure(
3583 &mut self,
3584 req: &ObserveRequest,
3585 index: u32,
3586 profile: &EpubExtractProfile,
3587 ) -> Result<FieldAnswer> {
3588 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3589 let json = epub_content_structure_json(index, &model, &item, profile);
3590 let provenance = format!(
3591 "epub;spine={index};part={};profile={};structure",
3592 item.resolved.as_deref().unwrap_or(""),
3593 profile.fingerprint()
3594 );
3595 Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, deps))
3596 }
3597
3598 fn epub_spine_item_preview(
3599 &mut self,
3600 req: &ObserveRequest,
3601 index: u32,
3602 profile: &EpubExtractProfile,
3603 ) -> Result<FieldAnswer> {
3604 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3605 let text = model.preview_text(
3606 index,
3607 item.resolved.as_deref().unwrap_or(""),
3608 &profile.fingerprint(),
3609 );
3610 let provenance = format!(
3611 "epub;spine={index};part={};profile={};preview",
3612 item.resolved.as_deref().unwrap_or(""),
3613 profile.fingerprint()
3614 );
3615 Ok(self.epub_answer(req, AnswerValue::Text(text), provenance, span, deps))
3616 }
3617
3618 fn epub_block(
3619 &mut self,
3620 req: &ObserveRequest,
3621 index: u32,
3622 block: u32,
3623 profile: &EpubExtractProfile,
3624 ) -> Result<FieldAnswer> {
3625 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3626 let b = model.blocks.get(block as usize).ok_or_else(|| {
3627 Error::unsupported_feature(format!("EPUB spine item {index} has no block {block}"))
3628 })?;
3629 let provenance = format!(
3630 "epub;spine={index};part={};block={block};profile={}",
3631 item.resolved.as_deref().unwrap_or(""),
3632 profile.fingerprint()
3633 );
3634 let value = match req.representation {
3635 Representation::Text => AnswerValue::Text(b.text()),
3636 Representation::Metadata | Representation::Structure => {
3637 AnswerValue::Json(epub_block_json(block, b))
3638 }
3639 _ => {
3640 return Err(Error::unsupported_feature(format!(
3641 "unsupported observation: selector {} with representation {}",
3642 req.selector.canonical(),
3643 req.representation.name()
3644 )));
3645 }
3646 };
3647 Ok(self.epub_answer(req, value, provenance, span, deps))
3648 }
3649
3650 fn epub_cell(
3651 &mut self,
3652 req: &ObserveRequest,
3653 index: u32,
3654 table: u32,
3655 row: u32,
3656 col: u32,
3657 profile: &EpubExtractProfile,
3658 ) -> Result<FieldAnswer> {
3659 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3660 let t = model.table(table).ok_or_else(|| {
3661 Error::unsupported_feature(format!("EPUB spine item {index} has no table {table}"))
3662 })?;
3663 let crate::adapter::epub::Block::Table { rows } = t else {
3664 return Err(Error::internal_invariant(
3665 "table selector resolved a non-table",
3666 ));
3667 };
3668 let r = rows.get(row as usize).ok_or_else(|| {
3669 Error::unsupported_feature(format!("EPUB table {table} has no row {row}"))
3670 })?;
3671 let c = r.cells.get(col as usize).ok_or_else(|| {
3672 Error::unsupported_feature(format!("EPUB table {table} row {row} has no cell {col}"))
3673 })?;
3674 let provenance = format!(
3675 "epub;spine={index};part={};table={table};row={row};col={col};profile={}",
3676 item.resolved.as_deref().unwrap_or(""),
3677 profile.fingerprint()
3678 );
3679 let value = match req.representation {
3680 Representation::Text => AnswerValue::Text(c.text.clone()),
3681 Representation::Metadata => AnswerValue::Json(epub_cell_json(table, row, col, c)),
3682 _ => {
3683 return Err(Error::unsupported_feature(format!(
3684 "unsupported observation: selector {} with representation {}",
3685 req.selector.canonical(),
3686 req.representation.name()
3687 )));
3688 }
3689 };
3690 Ok(self.epub_answer(req, value, provenance, span, deps))
3691 }
3692
3693 fn epub_link(
3694 &mut self,
3695 req: &ObserveRequest,
3696 index: u32,
3697 link: u32,
3698 profile: &EpubExtractProfile,
3699 ) -> Result<FieldAnswer> {
3700 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3701 let l = model.links.get(link as usize).ok_or_else(|| {
3702 Error::unsupported_feature(format!("EPUB spine item {index} has no link {link}"))
3703 })?;
3704 let provenance = format!(
3705 "epub;spine={index};part={};link={link};profile={}",
3706 item.resolved.as_deref().unwrap_or(""),
3707 profile.fingerprint()
3708 );
3709 Ok(self.epub_answer(
3710 req,
3711 AnswerValue::Json(epub_link_json(link, l)),
3712 provenance,
3713 span,
3714 deps,
3715 ))
3716 }
3717
3718 fn epub_find(
3719 &mut self,
3720 req: &ObserveRequest,
3721 index: u32,
3722 pattern: &str,
3723 profile: &EpubExtractProfile,
3724 ) -> Result<FieldAnswer> {
3725 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3726 let mut items: Vec<String> = Vec::new();
3727 let mut estimated: u64 = 0;
3728 for (i, b) in model.blocks.iter().enumerate() {
3729 let t = b.text();
3730 if t.contains(pattern) {
3731 estimated = estimated.saturating_add(t.len() as u64 + 48);
3732 if estimated > req.budget.max_output_bytes {
3733 return Err(Error::resource_limit(format!(
3734 "EPUB find exceeded the {}-byte budget",
3735 req.budget.max_output_bytes
3736 )));
3737 }
3738 items.push(format!(
3739 "{{\"block\":{i},\"kind\":\"{}\",\"text\":\"{}\"}}",
3740 b.kind(),
3741 json_escape(&t)
3742 ));
3743 }
3744 }
3745 let provenance = format!(
3746 "epub;spine={index};part={};profile={};find",
3747 item.resolved.as_deref().unwrap_or(""),
3748 profile.fingerprint()
3749 );
3750 Ok(self.epub_answer(
3751 req,
3752 AnswerValue::Json(format!("[{}]", items.join(","))),
3753 provenance,
3754 span,
3755 deps,
3756 ))
3757 }
3758
3759 fn epub_resource_meta(&mut self, req: &ObserveRequest, name: &str) -> Result<FieldAnswer> {
3760 let doc = self.epub_package_doc()?;
3761 let item = doc
3762 .manifest
3763 .iter()
3764 .find(|m| m.resolved.as_deref() == Some(name))
3765 .ok_or_else(|| {
3766 Error::invalid_package_structure(format!("no EPUB resource named {name:?}"))
3767 })?;
3768 let json = epub_manifest_json(item);
3769 let span = self.epub_member_span(item.resolved_ordinal());
3770 Ok(self.epub_answer(
3771 req,
3772 AnswerValue::Json(json),
3773 format!("epub;resource={name}"),
3774 span,
3775 Vec::new(),
3776 ))
3777 }
3778
3779 fn epub_resource_bytes(&mut self, req: &ObserveRequest, name: &str) -> Result<FieldAnswer> {
3780 let doc = self.epub_package_doc()?;
3781 let item = doc
3782 .manifest
3783 .iter()
3784 .find(|m| m.resolved.as_deref() == Some(name))
3785 .cloned()
3786 .ok_or_else(|| {
3787 Error::invalid_package_structure(format!("no EPUB resource named {name:?}"))
3788 })?;
3789 self.epub_item_bytes(req, &item)
3790 }
3791
3792 fn epub_nav(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3793 let doc = self.epub_package_doc()?;
3794 let idx = doc.nav_item.ok_or_else(|| {
3795 Error::invalid_package_structure(
3796 "EPUB package has no navigation document (properties nav)",
3797 )
3798 })?;
3799 let item = doc.manifest.get(idx as usize).cloned().ok_or_else(|| {
3800 Error::invalid_package_structure("EPUB nav manifest index is out of range")
3801 })?;
3802 let ordinal = item.resolved_ordinal().ok_or_else(|| {
3803 Error::invalid_package_structure("EPUB navigation document has no resolvable member")
3804 })?;
3805 let bytes = self.epub_member_decoded_bytes(ordinal)?;
3806 let base = epub_dir_of(item.resolved.as_deref().unwrap_or(""));
3807 let entries = crate::adapter::epub::parse_nav_document(&bytes, &base, self.limits)?;
3808 let body = entries
3809 .iter()
3810 .enumerate()
3811 .map(|(i, e)| epub_nav_json(i as u32, e))
3812 .collect::<Vec<_>>()
3813 .join(",");
3814 let json = format!(
3815 "{{\"nav_item\":\"{}\",\"entries\":[{}]}}",
3816 json_escape(&item.id),
3817 body
3818 );
3819 let provenance = format!(
3820 "epub;nav={};part={}",
3821 item.id,
3822 item.resolved.as_deref().unwrap_or("")
3823 );
3824 let span = self.epub_member_span(Some(ordinal));
3825 Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, Vec::new()))
3826 }
3827
3828 fn epub_nav_node(&mut self, req: &ObserveRequest, index: u32) -> Result<FieldAnswer> {
3829 let doc = self.epub_package_doc()?;
3830 let idx = doc.nav_item.ok_or_else(|| {
3831 Error::invalid_package_structure(
3832 "EPUB package has no navigation document (properties nav)",
3833 )
3834 })?;
3835 let item = doc.manifest.get(idx as usize).cloned().ok_or_else(|| {
3836 Error::invalid_package_structure("EPUB nav manifest index is out of range")
3837 })?;
3838 let ordinal = item.resolved_ordinal().ok_or_else(|| {
3839 Error::invalid_package_structure("EPUB navigation document has no resolvable member")
3840 })?;
3841 let bytes = self.epub_member_decoded_bytes(ordinal)?;
3842 let base = epub_dir_of(item.resolved.as_deref().unwrap_or(""));
3843 let entries = crate::adapter::epub::parse_nav_document(&bytes, &base, self.limits)?;
3844 let e = entries
3845 .get(index as usize)
3846 .ok_or_else(|| Error::unsupported_feature(format!("no EPUB nav entry {index}")))?;
3847 let json = epub_nav_json(index, e);
3848 Ok(self.epub_answer(
3849 req,
3850 AnswerValue::Json(json),
3851 format!("epub;nav-node={index}"),
3852 None,
3853 Vec::new(),
3854 ))
3855 }
3856}
3857
3858#[cfg(feature = "odt")]
3863type OdtContentView = (
3864 OdtContentModel,
3865 crate::adapter::odt::PartRef,
3866 Option<(u64, u64)>,
3867 Vec<NodeId>,
3868);
3869
3870#[cfg(feature = "odt")]
3871impl<S: SeedStore> Ctx<'_, S> {
3872 fn odt_model(&mut self) -> Result<OdtModel> {
3873 let entry = self.require_entry(SelectorKey::new(SEL_ODT_MODEL, 0), "ODT model")?;
3874 let node = self.load(&entry.node_id)?;
3875 let bytes = self.materialize(&node)?;
3876 OdtModel::decode(&bytes)
3877 }
3878
3879 fn odt_member_span(&mut self, ordinal: Option<u32>) -> Option<(u64, u64)> {
3880 let o = ordinal?;
3881 self.lookup(SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, o))
3882 .ok()?
3883 .into_iter()
3884 .next()
3885 .map(|e| (e.out_off, e.out_off.saturating_add(e.out_len)))
3886 }
3887
3888 fn odt_answer(
3889 &self,
3890 req: &ObserveRequest,
3891 value: AnswerValue,
3892 provenance: String,
3893 span: Option<(u64, u64)>,
3894 deps: Vec<NodeId>,
3895 ) -> FieldAnswer {
3896 FieldAnswer {
3897 value,
3898 basis: Basis::DeterministicallyDerived,
3899 selector: req.selector.canonical(),
3900 representation: req.representation.name().to_string(),
3901 source_span: span,
3902 provenance,
3903 dependency_ids: deps,
3904 integrity_scope: IntegrityScope::None,
3905 exact: false,
3906 }
3907 }
3908
3909 fn odt_content_view(&mut self, profile: &OdtExtractProfile) -> Result<OdtContentView> {
3912 let model = self.odt_model()?;
3913 let part = model.content.clone().ok_or_else(|| {
3914 Error::invalid_package_structure(
3915 "ODF package has no resolvable OpenDocument content part",
3916 )
3917 })?;
3918 if part.ordinal == u32::MAX {
3919 return Err(Error::invalid_package_structure(
3920 "ODF content part does not resolve to a package member",
3921 ));
3922 }
3923 let dec = self.require_entry(
3924 SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, part.ordinal),
3925 "ODT content decoded bytes",
3926 )?;
3927 self.stats.member_decodes = self.stats.member_decodes.saturating_add(1);
3928 let mut node = SeedNode::new(
3929 NodeKind::OdtContent,
3930 self.limits.max_output_bytes,
3931 crate::adapter::odt::content_params(part.ordinal, &part.name, profile),
3932 vec![dec.node_id],
3933 "odt:content",
3934 );
3935 node.limits.max_output_bytes = self.limits.max_output_bytes;
3936 let id = node.content_id();
3937 let bytes = self.materialize(&node)?;
3938 let cm = OdtContentModel::decode(&bytes)?;
3939 let span = self.odt_member_span(Some(part.ordinal));
3940 Ok((cm, part, span, vec![id, dec.node_id]))
3941 }
3942
3943 fn odt_part(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3945 let name = match &req.selector {
3946 Selector::OdtPart(n) => n.clone(),
3947 _ => return Err(Error::internal_invariant("odt_part: wrong selector")),
3948 };
3949 let model = self.odt_model()?;
3950 let entry = model
3951 .manifest
3952 .iter()
3953 .find(|e| e.full_path == name)
3954 .cloned()
3955 .ok_or_else(|| {
3956 Error::unsupported_feature(format!("ODF manifest declares no part {name:?}"))
3957 })?;
3958 if entry.ordinal == u32::MAX {
3959 return Err(Error::invalid_package_structure(format!(
3960 "ODF manifest part {name:?} does not resolve to a package member"
3961 )));
3962 }
3963 let provenance = format!(
3964 "odt;part={};ordinal={};media_type={}",
3965 entry.full_path, entry.ordinal, entry.media_type
3966 );
3967 match req.representation {
3968 Representation::Metadata => {
3969 let json = format!(
3970 "{{\"part\":\"{}\",\"ordinal\":{},\"media_type\":\"{}\",\"version\":{}}}",
3971 json_escape(&entry.full_path),
3972 entry.ordinal,
3973 json_escape(&entry.media_type),
3974 opt_str_json(entry.version.as_deref()),
3975 );
3976 Ok(self.odt_answer(req, AnswerValue::Json(json), provenance, None, Vec::new()))
3977 }
3978 Representation::ExactBytes => self.indexed_exact(
3979 req,
3980 SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, entry.ordinal),
3981 "ODT part",
3982 ),
3983 Representation::DecodedBytes => self.member_decoded(req, entry.ordinal),
3984 _ => Err(unsupported_common(req)),
3985 }
3986 }
3987
3988 fn odt_paragraph(
3989 &mut self,
3990 req: &ObserveRequest,
3991 index: u32,
3992 profile: &OdtExtractProfile,
3993 ) -> Result<FieldAnswer> {
3994 let (m, part, span, deps) = self.odt_content_view(profile)?;
3995 let p = m
3996 .paragraphs()
3997 .find(|p| !p.is_heading() && p.index == index)
3998 .ok_or_else(|| {
3999 Error::unsupported_feature(format!("ODT content has no paragraph {index}"))
4000 })?;
4001 let text = p.text.clone();
4002 let style = p.style_id.clone();
4003 let runs = p.runs.len();
4004 let provenance = format!(
4005 "odt;part={};paragraph={index};profile={}",
4006 part.name,
4007 profile.fingerprint()
4008 );
4009 let value = match req.representation {
4010 Representation::Text => AnswerValue::Text(text),
4011 Representation::Metadata => AnswerValue::Json(format!(
4012 "{{\"part\":\"{}\",\"paragraph\":{index},\"style\":{},\"runs\":{},\"text_len\":{}}}",
4013 json_escape(&part.name),
4014 opt_str_json(style.as_deref()),
4015 runs,
4016 text.len()
4017 )),
4018 _ => return Err(unsupported_common(req)),
4019 };
4020 Ok(self.odt_answer(req, value, provenance, span, deps))
4021 }
4022
4023 fn odt_heading(
4024 &mut self,
4025 req: &ObserveRequest,
4026 index: u32,
4027 profile: &OdtExtractProfile,
4028 ) -> Result<FieldAnswer> {
4029 let (m, part, span, deps) = self.odt_content_view(profile)?;
4030 let h = m.headings().find(|h| h.index == index).ok_or_else(|| {
4031 Error::unsupported_feature(format!("ODT content has no heading {index}"))
4032 })?;
4033 let text = h.text.clone();
4034 let level = h.heading_level;
4035 let style = h.style_id.clone();
4036 let provenance = format!(
4037 "odt;part={};heading={index};profile={}",
4038 part.name,
4039 profile.fingerprint()
4040 );
4041 let value = match req.representation {
4042 Representation::Text => AnswerValue::Text(text),
4043 Representation::Metadata => AnswerValue::Json(format!(
4044 "{{\"part\":\"{}\",\"heading\":{index},\"level\":{},\"style\":{},\"text_len\":{}}}",
4045 json_escape(&part.name),
4046 opt_u8_json(level),
4047 opt_str_json(style.as_deref()),
4048 text.len()
4049 )),
4050 _ => return Err(unsupported_common(req)),
4051 };
4052 Ok(self.odt_answer(req, value, provenance, span, deps))
4053 }
4054
4055 fn odt_table(
4056 &mut self,
4057 req: &ObserveRequest,
4058 index: u32,
4059 profile: &OdtExtractProfile,
4060 ) -> Result<FieldAnswer> {
4061 let (m, part, span, deps) = self.odt_content_view(profile)?;
4062 let t = m.tables().find(|t| t.index == index).ok_or_else(|| {
4063 Error::unsupported_feature(format!("ODT content has no table {index}"))
4064 })?;
4065 let text = t.text();
4066 let rows = t.rows.len();
4067 let cells: Vec<usize> = t.rows.iter().map(|r| r.cells.len()).collect();
4068 let provenance = format!(
4069 "odt;part={};table={index};profile={}",
4070 part.name,
4071 profile.fingerprint()
4072 );
4073 let value = match req.representation {
4074 Representation::Text => AnswerValue::Text(text),
4075 Representation::Metadata => {
4076 let dims = cells
4077 .iter()
4078 .map(|c| c.to_string())
4079 .collect::<Vec<_>>()
4080 .join(",");
4081 AnswerValue::Json(format!(
4082 "{{\"part\":\"{}\",\"table\":{index},\"rows\":{rows},\"cells_per_row\":[{dims}],\"profile\":\"{}\"}}",
4083 json_escape(&part.name),
4084 profile.fingerprint()
4085 ))
4086 }
4087 _ => return Err(unsupported_common(req)),
4088 };
4089 Ok(self.odt_answer(req, value, provenance, span, deps))
4090 }
4091
4092 fn odt_cell(
4093 &mut self,
4094 req: &ObserveRequest,
4095 table: u32,
4096 row: u32,
4097 col: u32,
4098 profile: &OdtExtractProfile,
4099 ) -> Result<FieldAnswer> {
4100 let (m, part, span, deps) = self.odt_content_view(profile)?;
4101 let t = m.tables().find(|t| t.index == table).ok_or_else(|| {
4102 Error::unsupported_feature(format!("ODT content has no table {table}"))
4103 })?;
4104 let r = t.rows.get(row as usize).ok_or_else(|| {
4105 Error::unsupported_feature(format!("ODT table {table} has no row {row}"))
4106 })?;
4107 let c = r.cells.iter().find(|c| c.grid_col == col).ok_or_else(|| {
4108 Error::unsupported_feature(format!("ODT table {table} row {row} has no cell {col}"))
4109 })?;
4110 let text = c.text.clone();
4111 let col_span = c.col_span;
4112 let row_span = c.row_span;
4113 let covered = c.covered;
4114 let provenance = format!(
4115 "odt;part={};table={table};row={row};cell={col};profile={}",
4116 part.name,
4117 profile.fingerprint()
4118 );
4119 let value = match req.representation {
4120 Representation::Text => AnswerValue::Text(text),
4121 Representation::Metadata => AnswerValue::Json(format!(
4122 concat!(
4123 "{{\"part\":\"{}\",\"table\":{},\"row\":{},\"col\":{},",
4124 "\"cols_spanned\":{},\"rows_spanned\":{},",
4125 "\"covered\":{},\"text_len\":{},\"profile\":\"{}\"}}"
4126 ),
4127 json_escape(&part.name),
4128 table,
4129 row,
4130 col,
4131 col_span,
4132 row_span,
4133 covered,
4134 text.len(),
4135 profile.fingerprint()
4136 )),
4137 _ => return Err(unsupported_common(req)),
4138 };
4139 Ok(self.odt_answer(req, value, provenance, span, deps))
4140 }
4141
4142 fn odt_list(
4143 &mut self,
4144 req: &ObserveRequest,
4145 index: u32,
4146 profile: &OdtExtractProfile,
4147 ) -> Result<FieldAnswer> {
4148 let (m, part, span, deps) = self.odt_content_view(profile)?;
4149 let l = m.lists().find(|l| l.index == index).ok_or_else(|| {
4150 Error::unsupported_feature(format!("ODT content has no list {index}"))
4151 })?;
4152 let text = l.text();
4153 let items = l.items.len();
4154 let provenance = format!(
4155 "odt;part={};list={index};profile={}",
4156 part.name,
4157 profile.fingerprint()
4158 );
4159 let value = match req.representation {
4160 Representation::Text => AnswerValue::Text(text),
4161 Representation::Metadata => AnswerValue::Json(format!(
4162 "{{\"part\":\"{}\",\"list\":{index},\"items\":{items},\"profile\":\"{}\"}}",
4163 json_escape(&part.name),
4164 profile.fingerprint()
4165 )),
4166 _ => return Err(unsupported_common(req)),
4167 };
4168 Ok(self.odt_answer(req, value, provenance, span, deps))
4169 }
4170
4171 fn odt_find(
4172 &mut self,
4173 req: &ObserveRequest,
4174 pattern: &str,
4175 profile: &OdtExtractProfile,
4176 ) -> Result<FieldAnswer> {
4177 let (m, part, span, deps) = self.odt_content_view(profile)?;
4178 let mut items: Vec<String> = Vec::new();
4179 let mut estimated: u64 = 0;
4180 for (i, b) in m.blocks.iter().enumerate() {
4181 let t = b.text();
4182 if t.contains(pattern) {
4183 estimated = estimated.saturating_add(t.len() as u64 + 48);
4184 if estimated > req.budget.max_output_bytes {
4185 return Err(Error::resource_limit(format!(
4186 "ODT find exceeded the {}-byte budget",
4187 req.budget.max_output_bytes
4188 )));
4189 }
4190 items.push(format!(
4191 "{{\"block\":{i},\"text\":\"{}\"}}",
4192 json_escape(&t)
4193 ));
4194 }
4195 }
4196 let provenance = format!("odt;part={};profile={}", part.name, profile.fingerprint());
4197 Ok(self.odt_answer(
4198 req,
4199 AnswerValue::Json(format!("[{}]", items.join(","))),
4200 provenance,
4201 span,
4202 deps,
4203 ))
4204 }
4205}
4206
4207impl<S: SeedStore> Ctx<'_, S> {
4212 fn document_format(&self) -> Option<DocumentFormat> {
4214 DocumentFormat::from_provenance(&self.manifest.provenance)
4215 }
4216
4217 fn tag_common(&self, fmt: DocumentFormat, mut answer: FieldAnswer) -> FieldAnswer {
4219 answer.provenance = format!("format={};common;{}", fmt.name(), answer.provenance);
4220 answer
4221 }
4222
4223 fn common_dispatch(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
4225 use crate::field::capabilities;
4226 let fmt = self.document_format().ok_or_else(|| {
4227 Error::unsupported_feature(
4228 "field manifest does not record a document format; common observations are unavailable",
4229 )
4230 })?;
4231 if !capabilities::common_supported(fmt, &req.selector, req.representation) {
4232 return Err(Error::unsupported_feature(format!(
4233 "unsupported common observation: format {} does not support selector {} with representation {}",
4234 fmt.name(),
4235 req.selector.canonical(),
4236 req.representation.name()
4237 )));
4238 }
4239 let answer = match fmt {
4240 DocumentFormat::Pdf => self.common_pdf(req)?,
4241 DocumentFormat::Docx => self.common_docx(req)?,
4242 DocumentFormat::Epub => self.common_epub(req)?,
4243 DocumentFormat::Odt => self.common_odt(req)?,
4244 DocumentFormat::Opaque => {
4245 return Err(Error::unsupported_feature(
4246 "opaque fields have no common observations",
4247 ));
4248 }
4249 };
4250 Ok(self.tag_common(fmt, answer))
4251 }
4252
4253 fn common_pdf(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
4256 match &req.selector {
4257 Selector::Metadata => {
4258 let mut a = self.document_metadata(req)?;
4259 a.provenance = "pdf;document-metadata".to_string();
4260 Ok(a)
4261 }
4262 Selector::Text => self.pdf_document_text(req),
4263 Selector::SearchMatch(p) => self.text_match(req, p),
4264 other => Err(Error::unsupported_feature(format!(
4265 "PDF does not support common selector {}",
4266 other.canonical()
4267 ))),
4268 }
4269 }
4270
4271 fn pdf_document_text(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
4274 let mut out = String::new();
4275 let mut pages: u64 = 0;
4276 let mut page: u32 = 1;
4277 while page <= MAX_TEXTMATCH_PAGES {
4278 let entries = self.lookup(SelectorKey::new(SEL_PAGE, page))?;
4279 let Some(entry) = entries.into_iter().next() else {
4280 break;
4281 };
4282 let pc = entry.node_id;
4283 let (_ops, text, _preview) = self.ensure_page_derived(page, pc)?;
4284 let bytes = self.materialize(&text)?;
4285 out.push_str(&String::from_utf8_lossy(&bytes));
4286 if !out.ends_with('\n') {
4287 out.push('\n');
4288 }
4289 if out.len() as u64 > req.budget.max_output_bytes {
4290 return Err(Error::resource_limit(format!(
4291 "whole-document text exceeded the {}-byte budget",
4292 req.budget.max_output_bytes
4293 )));
4294 }
4295 pages += 1;
4296 page += 1;
4297 }
4298 Ok(FieldAnswer {
4299 value: AnswerValue::Text(out),
4300 basis: Basis::Heuristic,
4301 selector: req.selector.canonical(),
4302 representation: req.representation.name().to_string(),
4303 source_span: None,
4304 provenance: format!("pdf;pages={pages}"),
4305 dependency_ids: Vec::new(),
4306 integrity_scope: IntegrityScope::None,
4307 exact: false,
4308 })
4309 }
4310
4311 #[cfg(feature = "docx")]
4314 fn common_docx(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
4315 let profile = DocxExtractProfile::DEFAULT;
4316 match &req.selector {
4317 Selector::Metadata => self.docx_common_metadata(req, &profile),
4318 Selector::Text => self.docx_story_text(req, DocxStory::Main, &profile),
4319 Selector::Heading(i) => self.docx_common_heading(req, *i, &profile),
4320 Selector::Block(i) => self.docx_common_block(req, *i, &profile),
4321 Selector::Table(i) => self.docx_table(req, DocxStory::Main, *i, &profile),
4322 Selector::Cell { table, row, col } => {
4323 let cell = a1_ref(*col, *row);
4324 self.docx_cell(req, DocxStory::Main, *table, &cell, &profile)
4325 }
4326 Selector::Resource(i) => self.docx_common_resource(req, *i, &profile),
4327 Selector::Link(i) => self.docx_common_link(req, *i, &profile),
4328 Selector::SearchMatch(p) => self.docx_find(req, DocxStory::Main, p, &profile),
4329 other => Err(Error::unsupported_feature(format!(
4330 "DOCX does not support common selector {}",
4331 other.canonical()
4332 ))),
4333 }
4334 }
4335
4336 #[cfg(not(feature = "docx"))]
4337 fn common_docx(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
4338 Err(Error::unsupported_feature(
4339 "DOCX observations require a build with the docx feature",
4340 ))
4341 }
4342
4343 #[cfg(feature = "docx")]
4344 fn docx_common_metadata(
4345 &mut self,
4346 req: &ObserveRequest,
4347 profile: &DocxExtractProfile,
4348 ) -> Result<FieldAnswer> {
4349 let v = self.docx_story_view(DocxStory::Main, profile)?;
4350 let json = format!(
4351 concat!(
4352 "{{\"format\":\"docx\",\"story\":\"main\",\"part\":\"{}\",\"ordinal\":{},",
4353 "\"root\":\"{}\",\"blocks\":{},\"paragraphs\":{},\"tables\":{},",
4354 "\"hyperlinks\":{},\"bookmarks\":{},\"resources\":{},\"sections\":{},",
4355 "\"profile\":\"{}\"}}"
4356 ),
4357 json_escape(&v.part.name),
4358 v.part.ordinal,
4359 json_escape(&v.model.root_local),
4360 v.model.blocks.len(),
4361 v.model.paragraphs().count(),
4362 v.model.tables().count(),
4363 v.model.hyperlinks.len(),
4364 v.model.bookmarks.len(),
4365 v.model.resources.len(),
4366 v.model.section_count,
4367 profile.fingerprint(),
4368 );
4369 let provenance = format!("docx;story=main;part={}", v.part.name);
4370 Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
4371 }
4372
4373 #[cfg(feature = "docx")]
4374 fn docx_common_heading(
4375 &mut self,
4376 req: &ObserveRequest,
4377 ordinal: u32,
4378 profile: &DocxExtractProfile,
4379 ) -> Result<FieldAnswer> {
4380 let v = self.docx_story_view(DocxStory::Main, profile)?;
4381 let p = v
4382 .model
4383 .paragraphs()
4384 .filter(|p| p.heading_level.is_some())
4385 .nth(ordinal as usize)
4386 .ok_or_else(|| {
4387 Error::unsupported_feature(format!("DOCX main story has no heading {ordinal}"))
4388 })?;
4389 let index = p.index;
4390 let text = p.text.clone();
4391 let level = p.heading_level;
4392 let style = p.style_id.clone();
4393 let value = match req.representation {
4394 Representation::Text => AnswerValue::Text(text),
4395 Representation::Metadata => AnswerValue::Json(format!(
4396 "{{\"story\":\"main\",\"heading\":{ordinal},\"paragraph\":{index},\"level\":{},\"style\":{},\"text_len\":{}}}",
4397 opt_u8_json(level),
4398 opt_str_json(style.as_deref()),
4399 text.len()
4400 )),
4401 _ => return Err(unsupported_common(req)),
4402 };
4403 let provenance = format!(
4404 "docx;story=main;part={};heading={ordinal};paragraph={index};profile={}",
4405 v.part.name,
4406 profile.fingerprint()
4407 );
4408 Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
4409 }
4410
4411 #[cfg(feature = "docx")]
4412 fn docx_common_block(
4413 &mut self,
4414 req: &ObserveRequest,
4415 ordinal: u32,
4416 profile: &DocxExtractProfile,
4417 ) -> Result<FieldAnswer> {
4418 use crate::adapter::docx::wml::Block as WmlBlock;
4419 let v = self.docx_story_view(DocxStory::Main, profile)?;
4420 let b = v.model.blocks.get(ordinal as usize).ok_or_else(|| {
4421 Error::unsupported_feature(format!("DOCX main story has no block {ordinal}"))
4422 })?;
4423 let (kind, text, detail) = match b {
4424 WmlBlock::Paragraph(p) => (
4425 "paragraph",
4426 p.text.clone(),
4427 format!(
4428 "\"paragraph\":{},\"level\":{}",
4429 p.index,
4430 opt_u8_json(p.heading_level)
4431 ),
4432 ),
4433 WmlBlock::Table(t) => (
4434 "table",
4435 t.text(),
4436 format!("\"table\":{},\"rows\":{}", t.index, t.rows.len()),
4437 ),
4438 };
4439 let value = match req.representation {
4440 Representation::Text => AnswerValue::Text(text),
4441 Representation::Metadata => AnswerValue::Json(format!(
4442 "{{\"story\":\"main\",\"block\":{ordinal},\"kind\":\"{kind}\",{detail},\"text_len\":{}}}",
4443 text.len()
4444 )),
4445 _ => return Err(unsupported_common(req)),
4446 };
4447 let provenance = format!(
4448 "docx;story=main;part={};block={ordinal};profile={}",
4449 v.part.name,
4450 profile.fingerprint()
4451 );
4452 Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
4453 }
4454
4455 #[cfg(feature = "docx")]
4456 fn docx_common_resource(
4457 &mut self,
4458 req: &ObserveRequest,
4459 ordinal: u32,
4460 profile: &DocxExtractProfile,
4461 ) -> Result<FieldAnswer> {
4462 let v = self.docx_story_view(DocxStory::Main, profile)?;
4463 let rel = v.model.resources.get(ordinal as usize).ok_or_else(|| {
4464 Error::unsupported_feature(format!("DOCX main story has no resource {ordinal}"))
4465 })?;
4466 let json = format!(
4467 "{{\"story\":\"main\",\"resource\":{ordinal},\"rel\":\"{}\"}}",
4468 json_escape(rel)
4469 );
4470 let provenance = format!(
4471 "docx;story=main;part={};resource={ordinal};profile={}",
4472 v.part.name,
4473 profile.fingerprint()
4474 );
4475 Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
4476 }
4477
4478 #[cfg(feature = "docx")]
4479 fn docx_common_link(
4480 &mut self,
4481 req: &ObserveRequest,
4482 ordinal: u32,
4483 profile: &DocxExtractProfile,
4484 ) -> Result<FieldAnswer> {
4485 let v = self.docx_story_view(DocxStory::Main, profile)?;
4486 let l = v.model.hyperlinks.get(ordinal as usize).ok_or_else(|| {
4487 Error::unsupported_feature(format!("DOCX main story has no hyperlink {ordinal}"))
4488 })?;
4489 let json = format!(
4490 "{{\"story\":\"main\",\"link\":{ordinal},\"text\":\"{}\",\"rel_id\":{},\"anchor\":{}}}",
4491 json_escape(&l.text),
4492 opt_str_json(l.rel_id.as_deref()),
4493 opt_str_json(l.anchor.as_deref())
4494 );
4495 let provenance = format!(
4496 "docx;story=main;part={};link={ordinal};profile={}",
4497 v.part.name,
4498 profile.fingerprint()
4499 );
4500 Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
4501 }
4502
4503 #[cfg(feature = "epub")]
4506 fn common_epub(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
4507 let profile = EpubExtractProfile::DEFAULT;
4508 match &req.selector {
4509 Selector::Metadata => self.epub_package(req),
4510 Selector::Text => self.epub_common_text(req, &profile),
4511 Selector::Heading(i) => self.epub_common_block(req, *i, true, &profile),
4512 Selector::Block(i) => self.epub_common_block(req, *i, false, &profile),
4513 Selector::Table(i) => self.epub_common_table(req, *i, &profile),
4514 Selector::Cell { table, row, col } => {
4515 self.epub_common_cell(req, *table, *row, *col, &profile)
4516 }
4517 Selector::Resource(i) => self.epub_common_resource(req, *i, &profile),
4518 Selector::Link(i) => self.epub_common_link(req, *i, &profile),
4519 Selector::SearchMatch(p) => self.epub_common_search(req, p, &profile),
4520 other => Err(Error::unsupported_feature(format!(
4521 "EPUB does not support common selector {}",
4522 other.canonical()
4523 ))),
4524 }
4525 }
4526
4527 #[cfg(not(feature = "epub"))]
4528 fn common_epub(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
4529 Err(Error::unsupported_feature(
4530 "EPUB observations require a build with the epub feature",
4531 ))
4532 }
4533
4534 #[cfg(not(feature = "odt"))]
4535 fn common_odt(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
4536 Err(Error::unsupported_feature(
4537 "ODT observations require a build with the odt feature",
4538 ))
4539 }
4540
4541 #[cfg(feature = "epub")]
4542 fn epub_common_text(
4543 &mut self,
4544 req: &ObserveRequest,
4545 profile: &EpubExtractProfile,
4546 ) -> Result<FieldAnswer> {
4547 let doc = self.epub_package_doc()?;
4548 let order_len = doc.reading_order(profile).len() as u32;
4549 let mut out = String::new();
4550 let mut items: u64 = 0;
4551 for index in 0..order_len {
4552 let (model, _item, _span, _deps) = self.epub_content_view(index, profile)?;
4553 out.push_str(&model.text());
4554 if !out.ends_with('\n') {
4555 out.push('\n');
4556 }
4557 if out.len() as u64 > req.budget.max_output_bytes {
4558 return Err(Error::resource_limit(format!(
4559 "whole-document text exceeded the {}-byte budget",
4560 req.budget.max_output_bytes
4561 )));
4562 }
4563 items += 1;
4564 }
4565 let provenance = format!("epub;spine-items={items};profile={}", profile.fingerprint());
4566 Ok(self.epub_answer(req, AnswerValue::Text(out), provenance, None, Vec::new()))
4567 }
4568
4569 #[cfg(feature = "epub")]
4572 fn epub_common_block(
4573 &mut self,
4574 req: &ObserveRequest,
4575 ordinal: u32,
4576 headings_only: bool,
4577 profile: &EpubExtractProfile,
4578 ) -> Result<FieldAnswer> {
4579 let doc = self.epub_package_doc()?;
4580 let order_len = doc.reading_order(profile).len() as u32;
4581 let mut remaining = ordinal as usize;
4582 for index in 0..order_len {
4583 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
4584 for (local, b) in model.blocks.iter().enumerate() {
4585 if headings_only && !matches!(b, crate::adapter::epub::Block::Heading { .. }) {
4586 continue;
4587 }
4588 if remaining == 0 {
4589 let text = b.text();
4590 let value = match req.representation {
4591 Representation::Text => AnswerValue::Text(text),
4592 Representation::Metadata => {
4593 AnswerValue::Json(epub_block_json(local as u32, b))
4594 }
4595 _ => return Err(unsupported_common(req)),
4596 };
4597 let provenance = format!(
4598 "epub;spine={index};part={};block={local};ordinal={ordinal};profile={}",
4599 item.resolved.as_deref().unwrap_or(""),
4600 profile.fingerprint()
4601 );
4602 return Ok(self.epub_answer(req, value, provenance, span, deps));
4603 }
4604 remaining -= 1;
4605 }
4606 }
4607 let what = if headings_only { "heading" } else { "block" };
4608 Err(Error::unsupported_feature(format!(
4609 "EPUB reading order has no {what} {ordinal}"
4610 )))
4611 }
4612
4613 #[cfg(feature = "epub")]
4614 fn epub_common_table(
4615 &mut self,
4616 req: &ObserveRequest,
4617 ordinal: u32,
4618 profile: &EpubExtractProfile,
4619 ) -> Result<FieldAnswer> {
4620 let doc = self.epub_package_doc()?;
4621 let order_len = doc.reading_order(profile).len() as u32;
4622 let mut remaining = ordinal as usize;
4623 for index in 0..order_len {
4624 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
4625 for (local, b) in model.blocks.iter().enumerate() {
4626 if !matches!(b, crate::adapter::epub::Block::Table { .. }) {
4627 continue;
4628 }
4629 if remaining == 0 {
4630 let value = match req.representation {
4631 Representation::Text => AnswerValue::Text(b.text()),
4632 Representation::Metadata => {
4633 AnswerValue::Json(epub_block_json(local as u32, b))
4634 }
4635 _ => return Err(unsupported_common(req)),
4636 };
4637 let provenance = format!(
4638 "epub;spine={index};part={};table={local};ordinal={ordinal};profile={}",
4639 item.resolved.as_deref().unwrap_or(""),
4640 profile.fingerprint()
4641 );
4642 return Ok(self.epub_answer(req, value, provenance, span, deps));
4643 }
4644 remaining -= 1;
4645 }
4646 }
4647 Err(Error::unsupported_feature(format!(
4648 "EPUB reading order has no table {ordinal}"
4649 )))
4650 }
4651
4652 #[cfg(feature = "epub")]
4653 fn epub_common_cell(
4654 &mut self,
4655 req: &ObserveRequest,
4656 table: u32,
4657 row: u32,
4658 col: u32,
4659 profile: &EpubExtractProfile,
4660 ) -> Result<FieldAnswer> {
4661 use crate::adapter::epub::Block as EBlock;
4662 let doc = self.epub_package_doc()?;
4663 let order_len = doc.reading_order(profile).len() as u32;
4664 let mut remaining = table as usize;
4665 for index in 0..order_len {
4666 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
4667 for (local, b) in model.blocks.iter().enumerate() {
4668 let EBlock::Table { rows } = b else { continue };
4669 if remaining > 0 {
4670 remaining -= 1;
4671 continue;
4672 }
4673 let r = rows.get(row as usize).ok_or_else(|| {
4674 Error::unsupported_feature(format!("EPUB table {table} has no row {row}"))
4675 })?;
4676 let c = r.cells.get(col as usize).ok_or_else(|| {
4677 Error::unsupported_feature(format!(
4678 "EPUB table {table} row {row} has no cell {col}"
4679 ))
4680 })?;
4681 let value = match req.representation {
4682 Representation::Text => AnswerValue::Text(c.text.clone()),
4683 Representation::Metadata => {
4684 AnswerValue::Json(epub_cell_json(table, row, col, c))
4685 }
4686 _ => return Err(unsupported_common(req)),
4687 };
4688 let provenance = format!(
4689 "epub;spine={index};part={};table={local};row={row};col={col};profile={}",
4690 item.resolved.as_deref().unwrap_or(""),
4691 profile.fingerprint()
4692 );
4693 return Ok(self.epub_answer(req, value, provenance, span, deps));
4694 }
4695 }
4696 Err(Error::unsupported_feature(format!(
4697 "EPUB reading order has no table {table}"
4698 )))
4699 }
4700
4701 #[cfg(feature = "epub")]
4702 fn epub_common_resource(
4703 &mut self,
4704 req: &ObserveRequest,
4705 ordinal: u32,
4706 profile: &EpubExtractProfile,
4707 ) -> Result<FieldAnswer> {
4708 let doc = self.epub_package_doc()?;
4709 let resources: Vec<&ManifestItem> = doc
4710 .manifest
4711 .iter()
4712 .filter(|i| !is_document_media_type(&i.media_type))
4713 .collect();
4714 let item = resources
4715 .get(ordinal as usize)
4716 .copied()
4717 .ok_or_else(|| Error::unsupported_feature(format!("EPUB has no resource {ordinal}")))?;
4718 if req.representation == Representation::Metadata {
4719 let json = format!(
4720 concat!(
4721 "{{\"resource\":{},\"id\":\"{}\",\"href\":\"{}\",",
4722 "\"media_type\":\"{}\",\"member\":{},\"profile\":\"{}\"}}"
4723 ),
4724 ordinal,
4725 json_escape(&item.id),
4726 json_escape(&item.href),
4727 json_escape(&item.media_type),
4728 epub_opt_str(item.resolved.as_deref()),
4729 profile.fingerprint()
4730 );
4731 let provenance = format!(
4732 "epub;resource={ordinal};id={};profile={}",
4733 item.id,
4734 profile.fingerprint()
4735 );
4736 return Ok(self.epub_answer(
4737 req,
4738 AnswerValue::Json(json),
4739 provenance,
4740 None,
4741 Vec::new(),
4742 ));
4743 }
4744 self.epub_item_bytes(req, item)
4746 }
4747
4748 #[cfg(feature = "epub")]
4749 fn epub_common_link(
4750 &mut self,
4751 req: &ObserveRequest,
4752 ordinal: u32,
4753 profile: &EpubExtractProfile,
4754 ) -> Result<FieldAnswer> {
4755 let doc = self.epub_package_doc()?;
4756 let order_len = doc.reading_order(profile).len() as u32;
4757 let mut remaining = ordinal as usize;
4758 for index in 0..order_len {
4759 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
4760 for (local, l) in model.links.iter().enumerate() {
4761 if remaining == 0 {
4762 let provenance = format!(
4763 "epub;spine={index};part={};link={local};ordinal={ordinal};profile={}",
4764 item.resolved.as_deref().unwrap_or(""),
4765 profile.fingerprint()
4766 );
4767 return Ok(self.epub_answer(
4768 req,
4769 AnswerValue::Json(epub_link_json(local as u32, l)),
4770 provenance,
4771 span,
4772 deps,
4773 ));
4774 }
4775 remaining -= 1;
4776 }
4777 }
4778 Err(Error::unsupported_feature(format!(
4779 "EPUB reading order has no link {ordinal}"
4780 )))
4781 }
4782
4783 #[cfg(feature = "epub")]
4784 fn epub_common_search(
4785 &mut self,
4786 req: &ObserveRequest,
4787 pattern: &str,
4788 profile: &EpubExtractProfile,
4789 ) -> Result<FieldAnswer> {
4790 let doc = self.epub_package_doc()?;
4791 let order_len = doc.reading_order(profile).len() as u32;
4792 let mut items: Vec<String> = Vec::new();
4793 let mut estimated: u64 = 0;
4794 for index in 0..order_len {
4795 let (model, item, _span, _deps) = self.epub_content_view(index, profile)?;
4796 let _ = item;
4797 for (local, b) in model.blocks.iter().enumerate() {
4798 let t = b.text();
4799 if t.contains(pattern) {
4800 estimated = estimated.saturating_add(t.len() as u64 + 64);
4801 if estimated > req.budget.max_output_bytes {
4802 return Err(Error::resource_limit(format!(
4803 "EPUB search exceeded the {}-byte budget",
4804 req.budget.max_output_bytes
4805 )));
4806 }
4807 items.push(format!(
4808 "{{\"spine\":{index},\"block\":{local},\"kind\":\"{}\",\"text\":\"{}\"}}",
4809 b.kind(),
4810 json_escape(&t)
4811 ));
4812 }
4813 }
4814 }
4815 let provenance = format!("epub;search;profile={}", profile.fingerprint());
4816 Ok(self.epub_answer(
4817 req,
4818 AnswerValue::Json(format!("[{}]", items.join(","))),
4819 provenance,
4820 None,
4821 Vec::new(),
4822 ))
4823 }
4824}
4825
4826#[cfg(feature = "odt")]
4831impl<S: SeedStore> Ctx<'_, S> {
4832 fn common_odt(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
4833 let profile = OdtExtractProfile::DEFAULT;
4834 match &req.selector {
4835 Selector::Metadata => self.odt_common_metadata(req, &profile),
4836 Selector::Text => self.odt_content_text(req, &profile),
4837 Selector::Heading(i) => self.odt_common_heading(req, *i, &profile),
4838 Selector::Block(i) => self.odt_common_block(req, *i, &profile),
4839 Selector::Table(i) => self.odt_table(req, *i, &profile),
4840 Selector::Cell { table, row, col } => self.odt_cell(req, *table, *row, *col, &profile),
4841 Selector::Resource(i) => self.odt_common_resource(req, *i, &profile),
4842 Selector::Link(i) => self.odt_common_link(req, *i, &profile),
4843 Selector::SearchMatch(p) => self.odt_find(req, p, &profile),
4844 other => Err(Error::unsupported_feature(format!(
4845 "ODT does not support common selector {}",
4846 other.canonical()
4847 ))),
4848 }
4849 }
4850
4851 fn odt_content_text(
4852 &mut self,
4853 req: &ObserveRequest,
4854 profile: &OdtExtractProfile,
4855 ) -> Result<FieldAnswer> {
4856 let (m, part, span, deps) = self.odt_content_view(profile)?;
4857 let provenance = format!("odt;part={};profile={}", part.name, profile.fingerprint());
4858 Ok(self.odt_answer(req, AnswerValue::Text(m.text()), provenance, span, deps))
4859 }
4860
4861 fn odt_common_metadata(
4862 &mut self,
4863 req: &ObserveRequest,
4864 profile: &OdtExtractProfile,
4865 ) -> Result<FieldAnswer> {
4866 let model = self.odt_model()?;
4867 let part = model.content.as_ref().ok_or_else(|| {
4868 Error::invalid_package_structure(
4869 "ODF package has no resolvable OpenDocument content part",
4870 )
4871 })?;
4872 let (m, _part, span, deps) = self.odt_content_view(profile)?;
4873 let json = format!(
4874 concat!(
4875 "{{",
4876 "\"format\":\"odt\",",
4877 "\"part\":\"{}\",",
4878 "\"ordinal\":{},",
4879 "\"media_type\":{},",
4880 "\"root\":\"{}\",",
4881 "\"manifest_entries\":{},",
4882 "\"blocks\":{},",
4883 "\"paragraphs\":{},",
4884 "\"headings\":{},",
4885 "\"tables\":{},",
4886 "\"lists\":{},",
4887 "\"hyperlinks\":{},",
4888 "\"bookmarks\":{},",
4889 "\"notes\":{},",
4890 "\"resources\":{},",
4891 "\"sections\":{},",
4892 "\"profile\":\"{}\"",
4893 "}}"
4894 ),
4895 json_escape(&part.name),
4896 part.ordinal,
4897 opt_str_json(part.media_type.as_deref()),
4898 json_escape(&m.root_local),
4899 model.manifest.len(),
4900 m.blocks.len(),
4901 m.paragraphs().filter(|p| !p.is_heading()).count(),
4902 m.headings().count(),
4903 m.tables().count(),
4904 m.lists().count(),
4905 m.hyperlinks.len(),
4906 m.bookmarks.len(),
4907 m.notes.len(),
4908 m.resources.len(),
4909 m.sections.len(),
4910 profile.fingerprint(),
4911 );
4912 let provenance = format!("odt;part={};profile={}", part.name, profile.fingerprint());
4913 Ok(self.odt_answer(req, AnswerValue::Json(json), provenance, span, deps))
4914 }
4915
4916 fn odt_common_heading(
4917 &mut self,
4918 req: &ObserveRequest,
4919 ordinal: u32,
4920 profile: &OdtExtractProfile,
4921 ) -> Result<FieldAnswer> {
4922 let (m, part, span, deps) = self.odt_content_view(profile)?;
4923 let h = m.headings().nth(ordinal as usize).ok_or_else(|| {
4924 Error::unsupported_feature(format!("ODT content has no common heading {ordinal}"))
4925 })?;
4926 let text = h.text.clone();
4927 let level = h.heading_level;
4928 let index = h.index;
4929 let style = h.style_id.clone();
4930 let value = match req.representation {
4931 Representation::Text => AnswerValue::Text(text),
4932 Representation::Metadata => AnswerValue::Json(format!(
4933 "{{\"part\":\"{}\",\"heading\":{ordinal},\"index\":{index},\"level\":{},\"style\":{},\"text_len\":{}}}",
4934 json_escape(&part.name),
4935 opt_u8_json(level),
4936 opt_str_json(style.as_deref()),
4937 text.len()
4938 )),
4939 _ => return Err(unsupported_common(req)),
4940 };
4941 let provenance = format!(
4942 "odt;part={};heading={ordinal};profile={}",
4943 part.name,
4944 profile.fingerprint()
4945 );
4946 Ok(self.odt_answer(req, value, provenance, span, deps))
4947 }
4948
4949 fn odt_common_block(
4950 &mut self,
4951 req: &ObserveRequest,
4952 ordinal: u32,
4953 profile: &OdtExtractProfile,
4954 ) -> Result<FieldAnswer> {
4955 let (m, part, span, deps) = self.odt_content_view(profile)?;
4956 let b = m.blocks.get(ordinal as usize).ok_or_else(|| {
4957 Error::unsupported_feature(format!("ODT content has no block {ordinal}"))
4958 })?;
4959 let (kind, text) = match b {
4960 OdtBlock::Paragraph(p) => ("paragraph", p.text.clone()),
4961 OdtBlock::Table(t) => ("table", t.text()),
4962 OdtBlock::List(l) => ("list", l.text()),
4963 };
4964 let value = match req.representation {
4965 Representation::Text => AnswerValue::Text(text),
4966 Representation::Metadata => AnswerValue::Json(format!(
4967 "{{\"part\":\"{}\",\"block\":{ordinal},\"kind\":\"{kind}\",\"text_len\":{}}}",
4968 json_escape(&part.name),
4969 text.len()
4970 )),
4971 _ => return Err(unsupported_common(req)),
4972 };
4973 let provenance = format!(
4974 "odt;part={};block={ordinal};profile={}",
4975 part.name,
4976 profile.fingerprint()
4977 );
4978 Ok(self.odt_answer(req, value, provenance, span, deps))
4979 }
4980
4981 fn odt_common_resource(
4982 &mut self,
4983 req: &ObserveRequest,
4984 ordinal: u32,
4985 profile: &OdtExtractProfile,
4986 ) -> Result<FieldAnswer> {
4987 let (m, part, span, deps) = self.odt_content_view(profile)?;
4988 let r = m.resources.get(ordinal as usize).ok_or_else(|| {
4989 Error::unsupported_feature(format!("ODT content has no resource {ordinal}"))
4990 })?;
4991 let json = format!(
4992 "{{\"part\":\"{}\",\"resource\":{ordinal},\"href\":\"{}\",\"member\":{},\"external\":{}}}",
4993 json_escape(&part.name),
4994 json_escape(&r.href),
4995 opt_str_json(r.member.as_deref()),
4996 r.external,
4997 );
4998 let provenance = format!(
4999 "odt;part={};resource={ordinal};profile={}",
5000 part.name,
5001 profile.fingerprint()
5002 );
5003 Ok(self.odt_answer(req, AnswerValue::Json(json), provenance, span, deps))
5004 }
5005
5006 fn odt_common_link(
5007 &mut self,
5008 req: &ObserveRequest,
5009 ordinal: u32,
5010 profile: &OdtExtractProfile,
5011 ) -> Result<FieldAnswer> {
5012 let (m, part, span, deps) = self.odt_content_view(profile)?;
5013 let l = m.hyperlinks.get(ordinal as usize).ok_or_else(|| {
5014 Error::unsupported_feature(format!("ODT content has no hyperlink {ordinal}"))
5015 })?;
5016 let json = format!(
5017 "{{\"part\":\"{}\",\"link\":{ordinal},\"text\":\"{}\",\"href\":\"{}\",\"external\":{}}}",
5018 json_escape(&part.name),
5019 json_escape(&l.text),
5020 json_escape(&l.href),
5021 l.external,
5022 );
5023 let provenance = format!(
5024 "odt;part={};link={ordinal};profile={}",
5025 part.name,
5026 profile.fingerprint()
5027 );
5028 Ok(self.odt_answer(req, AnswerValue::Json(json), provenance, span, deps))
5029 }
5030}
5031
5032#[cfg(any(feature = "docx", feature = "epub", feature = "odt"))]
5035fn unsupported_common(req: &ObserveRequest) -> Error {
5036 Error::unsupported_feature(format!(
5037 "unsupported observation: selector {} with representation {}",
5038 req.selector.canonical(),
5039 req.representation.name()
5040 ))
5041}
5042
5043#[cfg(feature = "docx")]
5045fn a1_ref(col: u32, row: u32) -> String {
5046 let mut c = col + 1;
5047 let mut letters: Vec<char> = Vec::new();
5048 while c > 0 {
5049 let rem = ((c - 1) % 26) as u8;
5050 letters.push((b'A' + rem) as char);
5051 c = (c - 1) / 26;
5052 }
5053 letters.reverse();
5054 format!("{}{}", letters.into_iter().collect::<String>(), row + 1)
5055}
5056
5057#[cfg(feature = "epub")]
5059fn is_document_media_type(media_type: &str) -> bool {
5060 media_type == "application/xhtml+xml"
5061 || media_type == "application/oebps-package+xml"
5062 || media_type == "application/x-dtbncx+xml"
5063}
5064
5065pub(crate) fn derived_nodes(page: u32, page_content: NodeId) -> (SeedNode, SeedNode, SeedNode) {
5068 let ops = SeedNode::new(
5069 NodeKind::ContentOperators,
5070 0,
5071 u32_params(page),
5072 vec![page_content],
5073 "pdf:content-operators",
5074 );
5075 let text = SeedNode::new(
5076 NodeKind::TextRuns,
5077 0,
5078 Vec::new(),
5079 vec![ops.content_id()],
5080 "pdf:text-runs",
5081 );
5082 let preview = SeedNode::new(
5083 NodeKind::PagePreview,
5084 0,
5085 u32_params(page),
5086 vec![page_content],
5087 "pdf:page-preview",
5088 );
5089 (ops, text, preview)
5090}
5091
5092fn preview_stats(bytes: &[u8]) -> (u64, u64, u64) {
5094 let rendered = String::from_utf8_lossy(bytes);
5095 let mut text_bytes = 0u64;
5096 let mut draw_ops = 0u64;
5097 let mut path_ops = 0u64;
5098 for line in rendered.lines() {
5099 if let Some(v) = line.strip_prefix("text-bytes ") {
5100 text_bytes = v.trim().parse().unwrap_or(0);
5101 } else if let Some(v) = line.strip_prefix("draw-ops ") {
5102 draw_ops = v.trim().parse().unwrap_or(0);
5103 } else if let Some(v) = line.strip_prefix("path-ops ") {
5104 path_ops = v.trim().parse().unwrap_or(0);
5105 }
5106 }
5107 (text_bytes, draw_ops, path_ops)
5108}
5109
5110#[cfg(test)]
5111mod tests {
5112 use super::*;
5113 use crate::container::{Descriptor, ObjectSource};
5114 use crate::dra::{Op, Program};
5115 use crate::field::plan;
5116 use crate::store::FsSeedStore;
5117 use std::fs;
5118 use std::path::PathBuf;
5119
5120 fn temp_root(label: &str) -> PathBuf {
5121 let mut p = std::env::temp_dir();
5122 p.push(format!(
5123 "vole-observe-{label}-{}-{}",
5124 std::process::id(),
5125 std::time::SystemTime::now()
5126 .duration_since(std::time::UNIX_EPOCH)
5127 .unwrap()
5128 .as_nanos()
5129 ));
5130 p
5131 }
5132
5133 fn opaque_descriptor(source: &[u8]) -> Vec<u8> {
5134 let d = Descriptor {
5135 universe: crate::container::UNIVERSE.to_string(),
5136 source_format: crate::SOURCE_FORMAT_PDF,
5137 format_basis: "pdf:observe-test".to_string(),
5138 models: vec![],
5139 channels: vec![],
5140 objects: vec![ObjectSource::Inline(source.to_vec())],
5141 program: Program::new(vec![Op::EmitObject { object_id: 0 }]),
5142 observation_index: None,
5143 seek_directory: false,
5144 checkpoints: None,
5145 source_sha256: crate::integrity::sha256(source),
5146 source_len: source.len() as u64,
5147 };
5148 d.serialize().unwrap().0
5149 }
5150
5151 fn adler32(data: &[u8]) -> u32 {
5152 let mut a: u32 = 1;
5153 let mut b: u32 = 0;
5154 for &byte in data {
5155 a = (a + u32::from(byte)) % 65521;
5156 b = (b + a) % 65521;
5157 }
5158 (b << 16) | a
5159 }
5160
5161 fn zlib_stored(data: &[u8]) -> Vec<u8> {
5162 assert!(!data.is_empty());
5163 let mut out = vec![0x78, 0x01];
5164 let chunks: Vec<&[u8]> = data.chunks(0xFFFF).collect();
5165 for (i, chunk) in chunks.iter().enumerate() {
5166 let final_block = u8::from(i + 1 == chunks.len());
5167 out.push(final_block);
5168 let len = chunk.len() as u16;
5169 out.extend_from_slice(&len.to_le_bytes());
5170 out.extend_from_slice(&(!len).to_le_bytes());
5171 out.extend_from_slice(chunk);
5172 }
5173 out.extend_from_slice(&adler32(data).to_be_bytes());
5174 out
5175 }
5176
5177 struct PdfBuilder {
5178 buf: Vec<u8>,
5179 offsets: Vec<(u64, u64)>,
5180 }
5181
5182 impl PdfBuilder {
5183 fn new() -> Self {
5184 PdfBuilder {
5185 buf: Vec::new(),
5186 offsets: Vec::new(),
5187 }
5188 }
5189 fn text(&mut self, s: &str) {
5190 self.buf.extend_from_slice(s.as_bytes());
5191 }
5192 fn raw(&mut self, b: &[u8]) {
5193 self.buf.extend_from_slice(b);
5194 }
5195 fn obj(&mut self, number: u64, body: &[u8]) {
5196 self.offsets.push((number, self.buf.len() as u64));
5197 self.text(&format!("{number} 0 obj\n"));
5198 self.raw(body);
5199 self.text("\nendobj\n");
5200 }
5201 fn stream_obj(&mut self, number: u64, extra: &str, data: &[u8]) {
5202 self.offsets.push((number, self.buf.len() as u64));
5203 self.text(&format!(
5204 "{number} 0 obj\n<< /Length {}{extra} >>\nstream\n",
5205 data.len()
5206 ));
5207 self.raw(data);
5208 self.text("\nendstream\nendobj\n");
5209 }
5210 fn offset_of(&self, number: u64) -> u64 {
5211 self.offsets
5212 .iter()
5213 .find(|&&(n, _)| n == number)
5214 .map(|&(_, off)| off)
5215 .unwrap()
5216 }
5217 fn classic_trailer(&mut self, size: u64, extra: &str) {
5218 let xref = self.buf.len() as u64;
5219 self.text(&format!("xref\n0 {size}\n"));
5220 self.raw(b"0000000000 65535 f \n");
5221 for number in 1..size {
5222 let off = self.offset_of(number);
5223 self.text(&format!("{off:010} 00000 n \n"));
5224 }
5225 self.text(&format!(
5226 "trailer\n<< /Size {size}{extra} >>\nstartxref\n{xref}\n%%EOF\n"
5227 ));
5228 }
5229 }
5230
5231 fn fixture_pdf(with_image: bool) -> Vec<u8> {
5234 let content = b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n";
5235 let encoded = zlib_stored(content);
5236 let mut w = PdfBuilder::new();
5237 w.text("%PDF-1.5\n");
5238 w.obj(1, b"<< /Type /Catalog /Pages 2 0 R >>");
5239 w.obj(2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
5240 if with_image {
5241 w.obj(
5242 3,
5243 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> /Contents 4 0 R >>",
5244 );
5245 } else {
5246 w.obj(
5247 3,
5248 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
5249 );
5250 }
5251 w.stream_obj(4, " /Filter /FlateDecode", &encoded);
5252 w.obj(5, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
5253 if with_image {
5254 let image = vec![0x80u8; 256 * 256];
5255 let image_encoded = zlib_stored(&image);
5256 w.stream_obj(
5257 6,
5258 " /Type /XObject /Subtype /Image /Width 256 /Height 256 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode",
5259 &image_encoded,
5260 );
5261 w.classic_trailer(7, " /Root 1 0 R");
5262 } else {
5263 w.classic_trailer(6, " /Root 1 0 R");
5264 }
5265 w.buf
5266 }
5267
5268 struct Fixture {
5269 root: PathBuf,
5270 store: FieldStore,
5271 field: FieldId,
5272 source: Vec<u8>,
5273 }
5274
5275 impl Fixture {
5276 fn new(label: &str, with_image: bool) -> Fixture {
5277 let root = temp_root(label);
5278 let mut store = FieldStore::open(&root).unwrap();
5279 let source = fixture_pdf(with_image);
5280 let descriptor = opaque_descriptor(&source);
5281 let report = ingest::ingest_pdf(&mut store, &descriptor, Limits::DEFAULT).unwrap();
5282 Fixture {
5283 root,
5284 store,
5285 field: report.field,
5286 source,
5287 }
5288 }
5289 }
5290
5291 impl Drop for Fixture {
5292 fn drop(&mut self) {
5293 fs::remove_dir_all(&self.root).ok();
5294 }
5295 }
5296
5297 fn observe_req(
5298 fx: &mut Fixture,
5299 selector: Selector,
5300 representation: Representation,
5301 ) -> (FieldAnswer, ObserveStats) {
5302 let req = ObserveRequest::new(selector, representation);
5303 let (answer, stats, _field) =
5304 observe(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
5305 (answer, stats)
5306 }
5307
5308 #[test]
5309 fn document_full_document_is_exact() {
5310 let mut fx = Fixture::new("full", false);
5311 let (answer, stats) =
5312 observe_req(&mut fx, Selector::Document, Representation::FullDocument);
5313 assert_eq!(answer.value, AnswerValue::Bytes(fx.source.clone()));
5314 assert_eq!(answer.basis, Basis::DirectlyObserved);
5315 assert_eq!(answer.integrity_scope, IntegrityScope::WholeSource);
5316 assert!(answer.exact);
5317 assert_eq!(answer.source_span, Some((0, fx.source.len() as u64)));
5318 assert_eq!(stats.bytes_returned, fx.source.len() as u64);
5319 }
5320
5321 #[test]
5322 fn byte_range_returns_exact_bytes() {
5323 let mut fx = Fixture::new("range", false);
5324 let (answer, _) = observe_req(
5325 &mut fx,
5326 Selector::ByteRange { offset: 9, len: 8 },
5327 Representation::ExactBytes,
5328 );
5329 assert_eq!(answer.value, AnswerValue::Bytes(fx.source[9..17].to_vec()));
5330 assert_eq!(answer.source_span, Some((9, 17)));
5331 assert!(answer.exact);
5332 assert_eq!(answer.integrity_scope, IntegrityScope::Node);
5333 }
5334
5335 #[test]
5336 fn page_text_is_heuristic_and_nonempty() {
5337 let mut fx = Fixture::new("text", false);
5338 let (answer, _) = observe_req(&mut fx, Selector::Page(1), Representation::Text);
5339 match &answer.value {
5340 AnswerValue::Text(t) => assert!(t.contains("Hello"), "got {t:?}"),
5341 other => panic!("expected text, got {other:?}"),
5342 }
5343 assert_eq!(answer.basis, Basis::Heuristic);
5344 assert!(!answer.exact);
5345 }
5346
5347 #[test]
5353 fn page_structure_procedural_closure_excludes_image_seed_node() {
5354 let mut fx = Fixture::new("structure", true);
5355 let (answer, stats) = observe_req(&mut fx, Selector::Page(1), Representation::Structure);
5356 match &answer.value {
5357 AnswerValue::Json(j) => {
5358 assert!(j.contains("\"page\":1"), "got {j}");
5359 assert!(j.contains("\"content_streams\":[4]"), "got {j}");
5360 }
5361 other => panic!("expected json, got {other:?}"),
5362 }
5363 assert_eq!(answer.basis, Basis::DeterministicallyDerived);
5364 assert!(!answer.exact);
5365 assert!(
5367 stats.seed_bytes_read < 16 * 1024,
5368 "structure observation read {} seed bytes (expected < 16384)",
5369 stats.seed_bytes_read
5370 );
5371 assert!(
5374 stats.descriptor_bytes_read > 0,
5375 "the descriptor read must be accounted, not hidden"
5376 );
5377 assert!(
5378 stats.bytes_read >= stats.descriptor_bytes_read,
5379 "bytes_read {} must include descriptor_bytes_read {}",
5380 stats.bytes_read,
5381 stats.descriptor_bytes_read
5382 );
5383 assert_eq!(
5384 stats.bytes_read,
5385 stats
5386 .descriptor_bytes_read
5387 .saturating_add(stats.manifest_bytes_read)
5388 .saturating_add(stats.index_bytes_read)
5389 .saturating_add(stats.seed_bytes_read),
5390 "bytes_read must be the exact sum of the four physical classes"
5391 );
5392 }
5393
5394 #[test]
5395 fn plan_is_pure_and_deterministic() {
5396 let fx = Fixture::new("plan", false);
5397 let req = ObserveRequest::new(Selector::Page(1), Representation::Text);
5398 let field = Field::open(&fx.store, &fx.field, Limits::DEFAULT).unwrap();
5399 let before = fx.store.seeds().list_nodes().unwrap().len();
5400 let a = plan::plan(field.manifest(), &fx.store, &req).unwrap();
5401 let b = plan::plan(field.manifest(), &fx.store, &req).unwrap();
5402 assert_eq!(a, b);
5403 let after = fx.store.seeds().list_nodes().unwrap().len();
5404 assert_eq!(before, after, "plan must not add seed nodes");
5405 }
5406
5407 #[test]
5408 fn explain_json_keys_and_unsupported_pair() {
5409 use crate::field::explain;
5410 let mut fx = Fixture::new("explain", false);
5411 let req = ObserveRequest::new(Selector::Document, Representation::FullDocument);
5412 let (plan, actual) =
5413 explain::explain_analyze(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
5414 assert_eq!(
5415 plan.json,
5416 "{\"selector\":\"document\",\"representation\":\"full\",\"format\":\"pdf\",\"adapter\":\"pdf\",\"capability\":\"native\",\"index_route\":\"hier-index\",\"shape\":\"full_materialize\",\"index_reads\":0,\"required_nodes\":1,\"will_materialize\":[\"DocumentExact\"],\"will_not_materialize\":[]}"
5417 );
5418 let actual_json = actual.to_json();
5419 let mut keys = top_level_keys(&actual_json);
5420 keys.sort();
5421 let mut expected = vec![
5422 "adapter",
5423 "basis",
5424 "bytes_read",
5425 "bytes_returned",
5426 "deepened",
5427 "descriptor_bytes_read",
5428 "descriptor_read_mode",
5429 "exact",
5430 "format",
5431 "index_bytes_read",
5432 "index_nodes_read",
5433 "inverse_work_units",
5434 "manifest_bytes_read",
5435 "member_decodes",
5436 "nodes_id_shared",
5437 "seed_bytes_read",
5438 "seed_nodes_fetched",
5439 "seed_nodes_materialized",
5440 "shared_resource_ids",
5441 "wall_micros",
5442 "whole_source_materialized",
5443 "xml_parses",
5444 ];
5445 expected.sort_unstable();
5446 assert_eq!(keys, expected, "actual json keys: {actual_json}");
5447
5448 let bad = ObserveRequest::new(Selector::Document, Representation::Text);
5450 let err = observe(&mut fx.store, &fx.field, &bad, Limits::DEFAULT).unwrap_err();
5451 assert_eq!(err.class(), crate::ErrorClass::UnsupportedFeature);
5452 let field = Field::open(&fx.store, &fx.field, Limits::DEFAULT).unwrap();
5453 let perr = plan::plan(field.manifest(), &fx.store, &bad).unwrap_err();
5454 assert_eq!(perr.class(), crate::ErrorClass::UnsupportedFeature);
5455 }
5456
5457 #[test]
5458 fn budget_yields_resource_limit_not_truncation() {
5459 let mut fx = Fixture::new("budget", false);
5460 let req = ObserveRequest {
5461 selector: Selector::Document,
5462 representation: Representation::FullDocument,
5463 budget: ObserveBudget {
5464 max_output_bytes: 4,
5465 max_nodes: 1 << 20,
5466 },
5467 use_cache: true,
5468 };
5469 let err = observe(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap_err();
5470 assert_eq!(err.class(), crate::ErrorClass::ResourceLimit);
5471 }
5472
5473 #[test]
5474 fn find_returns_matching_line() {
5475 let mut fx = Fixture::new("find", false);
5476 let (answer, _) = observe_req(
5477 &mut fx,
5478 Selector::TextMatch("Hello".to_string()),
5479 Representation::Text,
5480 );
5481 match &answer.value {
5482 AnswerValue::Json(j) => {
5483 assert!(j.contains("\"page\":1"), "got {j}");
5484 assert!(j.contains("Hello"), "got {j}");
5485 }
5486 other => panic!("expected json, got {other:?}"),
5487 }
5488 assert_eq!(answer.basis, Basis::Heuristic);
5489 }
5490
5491 #[test]
5492 fn preview_is_deterministic() {
5493 let mut fx = Fixture::new("preview", false);
5494 let (a, _) = observe_req(&mut fx, Selector::Page(1), Representation::Preview);
5495 let (b, _) = observe_req(&mut fx, Selector::Page(1), Representation::Preview);
5496 assert_eq!(a.value, b.value);
5497 match &a.value {
5498 AnswerValue::Bytes(bytes) => {
5499 assert!(String::from_utf8_lossy(bytes).contains("VOLE-PREVIEW v1"))
5500 }
5501 other => panic!("expected preview bytes, got {other:?}"),
5502 }
5503 }
5504
5505 #[test]
5506 fn decoded_and_operators_streams_resolve() {
5507 let mut fx = Fixture::new("decoded", false);
5508 let (decoded, _) = observe_req(&mut fx, Selector::Stream(4), Representation::DecodedBytes);
5509 match &decoded.value {
5510 AnswerValue::Bytes(b) => {
5511 assert_eq!(b, b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n");
5512 }
5513 other => panic!("expected bytes, got {other:?}"),
5514 }
5515 assert_eq!(decoded.basis, Basis::DeterministicallyDerived);
5516 let (ops, _) = observe_req(&mut fx, Selector::Stream(4), Representation::Operators);
5517 assert!(matches!(ops.value, AnswerValue::Bytes(ref b) if !b.is_empty()));
5518 }
5519
5520 #[test]
5522 fn observation_byte_classes_sum_and_are_all_charged() {
5523 let mut fx = Fixture::new("io-sum", false);
5524 let (_, stats) = observe_req(&mut fx, Selector::Page(1), Representation::Structure);
5525 assert!(
5526 stats.descriptor_bytes_read > 0,
5527 "descriptor bytes: {stats:?}"
5528 );
5529 assert!(stats.manifest_bytes_read > 0, "manifest bytes: {stats:?}");
5530 assert!(stats.index_bytes_read > 0, "index bytes: {stats:?}");
5531 assert!(stats.seed_bytes_read > 0, "seed bytes: {stats:?}");
5532 assert_eq!(
5533 stats.bytes_read,
5534 stats
5535 .descriptor_bytes_read
5536 .saturating_add(stats.manifest_bytes_read)
5537 .saturating_add(stats.index_bytes_read)
5538 .saturating_add(stats.seed_bytes_read),
5539 "bytes_read must equal the class sum: {stats:?}"
5540 );
5541 }
5542
5543 #[test]
5547 fn explain_analyze_opens_the_descriptor_once() {
5548 use crate::field::explain;
5549 let mut fx = Fixture::new("opens-once", false);
5550
5551 let req = ObserveRequest::new(Selector::Document, Representation::Metadata);
5553 let before = fx.store.io().descriptor_reads();
5554 let (_, actual) =
5555 explain::explain_analyze(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
5556 assert_eq!(
5557 fx.store.io().descriptor_reads() - before,
5558 1,
5559 "explain_analyze must open the descriptor exactly once"
5560 );
5561 assert!(
5562 actual.stats.descriptor_bytes_read > 0,
5563 "the single open must still be accounted: {:?}",
5564 actual.stats
5565 );
5566
5567 let page = ObserveRequest::new(Selector::Page(1), Representation::Text);
5570 let before = fx.store.io().descriptor_reads();
5571 let (_, page_actual) =
5572 explain::explain_analyze(&mut fx.store, &fx.field, &page, Limits::DEFAULT).unwrap();
5573 assert_eq!(
5574 fx.store.io().descriptor_reads() - before,
5575 1,
5576 "a cold promotion must not re-open the field for its manifest"
5577 );
5578 assert!(page_actual.stats.deepened, "the cold page must promote");
5579 assert!(page_actual.stats.descriptor_bytes_read > 0);
5580 }
5581
5582 #[test]
5593 fn session_answers_equal_cold_process_answers() {
5594 use crate::field::session::{DocumentFieldSession, SessionOptions};
5595 let mut fx = Fixture::new("session-eq", true);
5596 let reqs = [
5597 (Selector::Page(1), Representation::Text),
5598 (Selector::Page(1), Representation::Structure),
5599 (Selector::Page(1), Representation::Preview),
5600 (Selector::Stream(4), Representation::DecodedBytes),
5601 (Selector::Metadata, Representation::Metadata),
5602 ];
5603 let mut session =
5604 DocumentFieldSession::open(&fx.root, &fx.field.to_hex(), SessionOptions::default())
5605 .unwrap();
5606 for (i, (sel, rep)) in reqs.iter().enumerate() {
5607 let req = ObserveRequest::new(sel.clone(), *rep);
5608 let (cold, _cs, _ci) =
5609 observe(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
5610 let (warm, _ws, _wi) = session.observe(&req, Limits::DEFAULT).unwrap();
5611 assert_eq!(cold, warm, "FieldAnswer differs for request {i}");
5612 }
5613 }
5614
5615 #[test]
5619 fn session_reads_descriptor_once() {
5620 use crate::field::session::{DocumentFieldSession, SessionOptions};
5621 let fx = Fixture::new("session-open-once", false);
5622 let field = Field::open(&fx.store, &fx.field, Limits::DEFAULT).unwrap();
5625 let descriptor_len = field.descriptor_bytes().len() as u64;
5626 let mut session =
5627 DocumentFieldSession::open(&fx.root, &fx.field.to_hex(), SessionOptions::default())
5628 .unwrap();
5629 let req = ObserveRequest::new(Selector::Page(1), Representation::Text);
5630 let (_, s1, _) = session.observe(&req, Limits::DEFAULT).unwrap();
5631 let (_, s2, _) = session.observe(&req, Limits::DEFAULT).unwrap();
5632 let (_, s3, _) = session.observe(&req, Limits::DEFAULT).unwrap();
5633 assert_eq!(
5634 s1.descriptor_bytes_read, descriptor_len,
5635 "the first observation must carry exactly the one-time open: {s1:?}"
5636 );
5637 assert!(
5638 s1.manifest_bytes_read > 0,
5639 "the one-time open must include the manifest: {s1:?}"
5640 );
5641 assert_eq!(
5642 s2.descriptor_bytes_read, 0,
5643 "the second observation re-read the descriptor: {s2:?}"
5644 );
5645 assert_eq!(
5646 s3.descriptor_bytes_read, 0,
5647 "the third observation re-read the descriptor: {s3:?}"
5648 );
5649 assert_eq!(
5650 s2.manifest_bytes_read, 0,
5651 "the second observation re-read the manifest: {s2:?}"
5652 );
5653 assert_eq!(
5654 s3.manifest_bytes_read, 0,
5655 "the third observation re-read the manifest: {s3:?}"
5656 );
5657 }
5658
5659 #[test]
5664 fn session_probe_matches_narrow_probe_short_circuit() {
5665 use crate::field::session::{DocumentFieldSession, SessionOptions};
5666 let mut fx = Fixture::new("session-probe", false);
5667 let req = ObserveRequest::new(Selector::Page(1), Representation::Text);
5668 let (warm, _, _) = observe(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
5671 let (probe_answer, probe_stats, _) =
5672 observe(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
5673 assert_eq!(probe_answer, warm);
5674 assert_eq!(
5675 probe_stats.descriptor_read_mode,
5676 DescriptorReadMode::Partial,
5677 "the cold short-circuit did not fire: {probe_stats:?}"
5678 );
5679 assert_eq!(probe_stats.descriptor_bytes_read, 0, "{probe_stats:?}");
5680
5681 let mut session =
5685 DocumentFieldSession::open(&fx.root, &fx.field.to_hex(), SessionOptions::default())
5686 .unwrap();
5687 let (sess_answer, sess_stats, _) = session.observe(&req, Limits::DEFAULT).unwrap();
5688 assert_eq!(
5689 sess_answer, probe_answer,
5690 "session probe answer differs from the cold narrow_probe"
5691 );
5692 assert_eq!(
5693 sess_stats.descriptor_read_mode,
5694 DescriptorReadMode::Partial,
5695 "the session did not use the probe short-circuit: {sess_stats:?}"
5696 );
5697 }
5698
5699 struct BoundedSeedStore {
5703 inner: FsSeedStore,
5704 fetches: Cell<u64>,
5705 limit: u64,
5706 }
5707
5708 impl SeedStore for BoundedSeedStore {
5709 fn put_node(&mut self, canonical: &[u8]) -> Result<NodeId> {
5710 self.inner.put_node(canonical)
5711 }
5712
5713 fn get_node(&self, id: &NodeId) -> Result<Vec<u8>> {
5714 let n = self.fetches.get() + 1;
5715 assert!(
5716 n <= self.limit,
5717 "get_node #{n} exceeds the {}-fetch bound: the store was enumerated",
5718 self.limit
5719 );
5720 self.fetches.set(n);
5721 self.inner.get_node(id)
5722 }
5723
5724 fn get_node_range(&self, id: &NodeId, offset: u64, len: u64) -> Result<Vec<u8>> {
5725 let n = self.fetches.get() + 1;
5726 assert!(
5727 n <= self.limit,
5728 "get_node_range #{n} exceeds the {}-fetch bound: the store was enumerated",
5729 self.limit
5730 );
5731 self.fetches.set(n);
5732 self.inner.get_node_range(id, offset, len)
5733 }
5734
5735 fn contains_node(&self, id: &NodeId) -> Result<bool> {
5736 self.inner.contains_node(id)
5737 }
5738
5739 fn list_nodes(&self) -> Result<Vec<(NodeId, u64)>> {
5740 panic!("an observation must never enumerate the seed store")
5741 }
5742 }
5743
5744 #[test]
5745 fn decoded_stream_resolves_without_enumerating_the_store() {
5746 let mut fx = Fixture::new("no-scan", true);
5747 for i in 0..256u32 {
5749 let decoy = SeedNode::new(
5750 NodeKind::PdfObject,
5751 1,
5752 u32_params(10_000 + i),
5753 Vec::new(),
5754 "decoy",
5755 );
5756 fx.store
5757 .seeds_mut()
5758 .put_node(&decoy.encode_canonical())
5759 .unwrap();
5760 }
5761 let total = fx.store.seeds().list_nodes().unwrap().len() as u64;
5762 assert!(total > 200, "expected many seed nodes, got {total}");
5763
5764 let field = Field::open(&fx.store, &fx.field, Limits::DEFAULT).unwrap();
5765 let io = fx.store.io().handle();
5766 let seeds = CountingSeedStore::new(BoundedSeedStore {
5767 inner: FsSeedStore::open_with_io(fx.store.root(), io.handle()).unwrap(),
5768 fetches: Cell::new(0),
5769 limit: 8,
5770 });
5771 let istore = FsIndexStore::open_with_io(fx.store.root(), io.handle()).unwrap();
5772 let req = ObserveRequest::new(Selector::Stream(4), Representation::DecodedBytes);
5773 let (answer, stats, _) = observe_with_stores(
5774 &mut fx.store,
5775 FieldView::from_field(&field),
5776 &req,
5777 Limits::DEFAULT,
5778 Instant::now(),
5779 seeds,
5780 istore,
5781 )
5782 .unwrap();
5783 match &answer.value {
5784 AnswerValue::Bytes(b) => {
5785 assert_eq!(b, b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n")
5786 }
5787 other => panic!("expected bytes, got {other:?}"),
5788 }
5789 assert!(
5790 stats.seed_nodes_fetched <= 8,
5791 "fetched {} seed nodes for one decoded stream",
5792 stats.seed_nodes_fetched
5793 );
5794 assert!(
5795 stats.seed_nodes_fetched < total,
5796 "must not enumerate the {total}-node store; fetched {}",
5797 stats.seed_nodes_fetched
5798 );
5799 }
5800
5801 pub(super) fn top_level_keys(json: &str) -> Vec<String> {
5803 let b = json.as_bytes();
5804 let mut keys = Vec::new();
5805 let mut depth = 0i32;
5806 let mut in_str = false;
5807 let mut esc = false;
5808 let mut i = 0;
5809 while i < b.len() {
5810 let c = b[i];
5811 if in_str {
5812 if esc {
5813 esc = false;
5814 } else if c == b'\\' {
5815 esc = true;
5816 } else if c == b'"' {
5817 in_str = false;
5818 if depth == 1 && b.get(i + 1) == Some(&b':') {
5819 let mut j = i;
5820 while j > 0 {
5821 j -= 1;
5822 if b[j] == b'"' {
5823 keys.push(String::from_utf8_lossy(&b[j + 1..i]).into_owned());
5824 break;
5825 }
5826 }
5827 }
5828 }
5829 } else {
5830 match c {
5831 b'"' => in_str = true,
5832 b'{' | b'[' => depth += 1,
5833 b'}' | b']' => depth -= 1,
5834 _ => {}
5835 }
5836 }
5837 i += 1;
5838 }
5839 keys
5840 }
5841}