1use std::cell::Cell;
27use std::time::Instant;
28
29#[cfg(feature = "docx")]
30use crate::adapter::docx::wml::StoryModel;
31#[cfg(feature = "docx")]
32use crate::adapter::docx::{DocxExtractProfile, DocxModel, DocxPartRef, DocxStory, story_params};
33#[cfg(feature = "epub")]
34use crate::adapter::epub::{EpubExtractProfile, EpubModel, ManifestItem, PackageDoc};
35use crate::error::{Error, Result};
36use crate::field::cache::DerivedCache;
37use crate::field::dag::{self, EvalBudget, ReuseStats, SourceServer};
38use crate::field::document_format::DocumentFormat;
39#[cfg(feature = "docx")]
40use crate::field::index::SEL_DOCX_MODEL;
41#[cfg(feature = "epub")]
42use crate::field::index::SEL_EPUB_MODEL;
43#[cfg(feature = "opc")]
44use crate::field::index::SEL_OPC_MODEL;
45use crate::field::index::{
46 FsIndexStore, IndexEntry, SEL_OBJECT, SEL_PACKAGE_MEMBER_DECODED, SEL_PACKAGE_MEMBER_RAW,
47 SEL_PAGE, SEL_REVISION, SEL_STREAM, SEL_STREAM_DECODED, SelectorKey, lookup,
48};
49use crate::field::ingest;
50use crate::field::manifest::FieldRoot;
51use crate::field::node::{NodeKind, SeedNode, read_u32_params, span_params, u32_params};
52use crate::field::partial::{PartialDescriptor, PartialLoad};
53use crate::field::{Field, FieldId, FieldStore, SeedSubstrate};
54use crate::limits::Limits;
55use crate::store::{Id, IoSnapshot, NodeId, SeedStore};
56
57use super::provenance::{AnswerValue, Basis, FieldAnswer, IntegrityScope, json_escape};
58
59const MAX_TEXTMATCH_PAGES: u32 = 1 << 20;
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum Selector {
65 Document,
67 Page(u32),
69 Object(u32),
71 Stream(u32),
73 Revision(u32),
75 Member(u32),
78 PackagePart(String),
82 Relationship(String),
85 ByteRange {
87 offset: u64,
89 len: u64,
91 },
92 TextMatch(String),
94 Metadata,
98 Text,
100 Heading(u32),
102 Block(u32),
104 Table(u32),
106 Cell {
108 table: u32,
110 row: u32,
112 col: u32,
114 },
115 Resource(u32),
118 Link(u32),
120 SearchMatch(String),
123 #[cfg(feature = "docx")]
126 DocxStory {
127 story: DocxStory,
129 profile: DocxExtractProfile,
131 },
132 #[cfg(feature = "docx")]
134 DocxParagraph {
135 story: DocxStory,
137 index: u32,
139 profile: DocxExtractProfile,
141 },
142 #[cfg(feature = "docx")]
144 DocxTable {
145 story: DocxStory,
147 index: u32,
149 profile: DocxExtractProfile,
151 },
152 #[cfg(feature = "docx")]
154 DocxCell {
155 story: DocxStory,
157 table: u32,
159 cell: String,
161 profile: DocxExtractProfile,
163 },
164 #[cfg(feature = "docx")]
166 DocxFind {
167 story: DocxStory,
169 pattern: String,
171 profile: DocxExtractProfile,
173 },
174 #[cfg(feature = "epub")]
176 EpubPackage,
177 #[cfg(feature = "epub")]
181 EpubManifestItem {
182 index: u32,
184 },
185 #[cfg(feature = "epub")]
189 EpubSpineItem {
190 index: u32,
192 profile: EpubExtractProfile,
194 },
195 #[cfg(feature = "epub")]
197 EpubNav,
198 #[cfg(feature = "epub")]
200 EpubNavNode {
201 index: u32,
203 },
204 #[cfg(feature = "epub")]
207 EpubResource(String),
208 #[cfg(feature = "epub")]
212 EpubBlock {
213 index: u32,
215 block: u32,
217 profile: EpubExtractProfile,
219 },
220 #[cfg(feature = "epub")]
224 EpubCell {
225 index: u32,
227 table: u32,
229 row: u32,
231 col: u32,
233 profile: EpubExtractProfile,
235 },
236 #[cfg(feature = "epub")]
238 EpubLink {
239 index: u32,
241 link: u32,
243 profile: EpubExtractProfile,
245 },
246 #[cfg(feature = "epub")]
248 EpubFind {
249 index: u32,
251 pattern: String,
253 profile: EpubExtractProfile,
255 },
256}
257
258impl Selector {
259 pub fn canonical(&self) -> String {
261 match self {
262 Selector::Document => "document".to_string(),
263 Selector::Page(n) => format!("page:{n}"),
264 Selector::Object(n) => format!("object:{n}"),
265 Selector::Stream(n) => format!("stream:{n}"),
266 Selector::Revision(n) => format!("revision:{n}"),
267 Selector::Member(n) => format!("member:{n}"),
268 Selector::PackagePart(name) => format!("package-part:{name}"),
269 Selector::Relationship(id) => format!("relationship:{id}"),
270 Selector::ByteRange { offset, len } => format!("byte-range:{offset}:{len}"),
271 Selector::TextMatch(p) => format!("text-match:{p}"),
272 Selector::Metadata => "metadata".to_string(),
273 Selector::Text => "text".to_string(),
274 Selector::Heading(n) => format!("heading:{n}"),
275 Selector::Block(n) => format!("block:{n}"),
276 Selector::Table(n) => format!("table:{n}"),
277 Selector::Cell { table, row, col } => format!("cell:{table}:{row}:{col}"),
278 Selector::Resource(n) => format!("resource:{n}"),
279 Selector::Link(n) => format!("link:{n}"),
280 Selector::SearchMatch(p) => format!("search-match:{p}"),
281 #[cfg(feature = "docx")]
282 Selector::DocxStory { story, profile } => {
283 format!(
284 "docx-story:{};profile={}",
285 story.name(),
286 profile.fingerprint()
287 )
288 }
289 #[cfg(feature = "docx")]
290 Selector::DocxParagraph {
291 story,
292 index,
293 profile,
294 } => format!(
295 "docx-paragraph:{}:{};profile={}",
296 story.name(),
297 index,
298 profile.fingerprint()
299 ),
300 #[cfg(feature = "docx")]
301 Selector::DocxTable {
302 story,
303 index,
304 profile,
305 } => format!(
306 "docx-table:{}:{};profile={}",
307 story.name(),
308 index,
309 profile.fingerprint()
310 ),
311 #[cfg(feature = "docx")]
312 Selector::DocxCell {
313 story,
314 table,
315 cell,
316 profile,
317 } => format!(
318 "docx-cell:{}:{}:{};profile={}",
319 story.name(),
320 table,
321 cell,
322 profile.fingerprint()
323 ),
324 #[cfg(feature = "docx")]
325 Selector::DocxFind {
326 story,
327 pattern,
328 profile,
329 } => format!(
330 "docx-find:{}:{};profile={}",
331 story.name(),
332 pattern,
333 profile.fingerprint()
334 ),
335 #[cfg(feature = "epub")]
336 Selector::EpubPackage => "epub-package".to_string(),
337 #[cfg(feature = "epub")]
338 Selector::EpubManifestItem { index } => format!("epub-manifest-item:{index}"),
339 #[cfg(feature = "epub")]
340 Selector::EpubSpineItem { index, profile } => {
341 format!("epub-spine-item:{index};profile={}", profile.fingerprint())
342 }
343 #[cfg(feature = "epub")]
344 Selector::EpubNav => "epub-nav".to_string(),
345 #[cfg(feature = "epub")]
346 Selector::EpubNavNode { index } => format!("epub-nav-node:{index}"),
347 #[cfg(feature = "epub")]
348 Selector::EpubResource(name) => format!("epub-resource:{name}"),
349 #[cfg(feature = "epub")]
350 Selector::EpubBlock {
351 index,
352 block,
353 profile,
354 } => format!(
355 "epub-block:{index}:{block};profile={}",
356 profile.fingerprint()
357 ),
358 #[cfg(feature = "epub")]
359 Selector::EpubCell {
360 index,
361 table,
362 row,
363 col,
364 profile,
365 } => format!(
366 "epub-cell:{index}:{table}:{row}:{col};profile={}",
367 profile.fingerprint()
368 ),
369 #[cfg(feature = "epub")]
370 Selector::EpubLink {
371 index,
372 link,
373 profile,
374 } => format!("epub-link:{index}:{link};profile={}", profile.fingerprint()),
375 #[cfg(feature = "epub")]
376 Selector::EpubFind {
377 index,
378 pattern,
379 profile,
380 } => format!(
381 "epub-find:{index}:{pattern};profile={}",
382 profile.fingerprint()
383 ),
384 }
385 }
386
387 pub fn is_common(&self) -> bool {
391 matches!(
392 self,
393 Selector::Metadata
394 | Selector::Text
395 | Selector::Heading(_)
396 | Selector::Block(_)
397 | Selector::Table(_)
398 | Selector::Cell { .. }
399 | Selector::Resource(_)
400 | Selector::Link(_)
401 | Selector::SearchMatch(_)
402 )
403 }
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum Representation {
409 Metadata,
411 Text,
413 Structure,
415 Operators,
417 EncodedBytes,
419 DecodedBytes,
421 ExactBytes,
423 Preview,
425 FullDocument,
427}
428
429impl Representation {
430 pub const fn name(self) -> &'static str {
432 match self {
433 Representation::Metadata => "metadata",
434 Representation::Text => "text",
435 Representation::Structure => "structure",
436 Representation::Operators => "operators",
437 Representation::EncodedBytes => "encoded",
438 Representation::DecodedBytes => "decoded",
439 Representation::ExactBytes => "exact",
440 Representation::Preview => "preview",
441 Representation::FullDocument => "full",
442 }
443 }
444}
445
446#[derive(Debug, Clone, Copy)]
448pub struct ObserveBudget {
449 pub max_output_bytes: u64,
451 pub max_nodes: u64,
453}
454
455impl Default for ObserveBudget {
456 fn default() -> Self {
457 ObserveBudget {
458 max_output_bytes: 64 * 1024 * 1024,
459 max_nodes: 1 << 20,
460 }
461 }
462}
463
464#[derive(Debug, Clone)]
466pub struct ObserveRequest {
467 pub selector: Selector,
469 pub representation: Representation,
471 pub budget: ObserveBudget,
473 pub use_cache: bool,
476}
477
478impl ObserveRequest {
479 pub fn new(selector: Selector, representation: Representation) -> Self {
481 ObserveRequest {
482 selector,
483 representation,
484 budget: ObserveBudget::default(),
485 use_cache: true,
486 }
487 }
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
492pub enum DescriptorReadMode {
493 #[default]
495 Full,
496 Partial,
498}
499
500impl DescriptorReadMode {
501 pub const fn name(self) -> &'static str {
503 match self {
504 DescriptorReadMode::Full => "full",
505 DescriptorReadMode::Partial => "partial",
506 }
507 }
508}
509
510#[derive(Debug, Clone, Default, PartialEq, Eq)]
517pub struct ObserveStats {
518 pub index_nodes_read: u64,
521 pub seed_nodes_fetched: u64,
523 pub seed_nodes_materialized: u64,
527 pub seed_nodes_executed: u64,
529 pub seed_nodes_reused: u64,
532 pub cache_bytes_written: u64,
534 pub nodes_id_shared: u64,
540 pub shared_resource_ids: u64,
543 pub descriptor_bytes_read: u64,
548 pub descriptor_read_mode: DescriptorReadMode,
551 pub manifest_bytes_read: u64,
553 pub index_bytes_read: u64,
555 pub seed_bytes_read: u64,
557 pub bytes_read: u64,
562 pub bytes_returned: u64,
564 pub deepened: bool,
566 pub member_decodes: u64,
572 pub xml_parses: u64,
576 pub wall_micros: u64,
578}
579
580struct CountingSeedStore<S: SeedStore> {
585 inner: S,
586 gets: Cell<u64>,
587}
588
589impl<S: SeedStore> CountingSeedStore<S> {
590 fn new(inner: S) -> Self {
591 CountingSeedStore {
592 inner,
593 gets: Cell::new(0),
594 }
595 }
596
597 fn gets(&self) -> u64 {
598 self.gets.get()
599 }
600
601 fn note(&self) {
602 self.gets.set(self.gets.get() + 1);
603 }
604}
605
606impl<S: SeedStore> SeedStore for CountingSeedStore<S> {
607 fn put_node(&mut self, canonical: &[u8]) -> Result<NodeId> {
608 self.inner.put_node(canonical)
609 }
610
611 fn get_node(&self, id: &NodeId) -> Result<Vec<u8>> {
612 self.note();
613 self.inner.get_node(id)
614 }
615
616 fn get_node_range(&self, id: &NodeId, offset: u64, len: u64) -> Result<Vec<u8>> {
617 self.note();
618 self.inner.get_node_range(id, offset, len)
619 }
620
621 fn contains_node(&self, id: &NodeId) -> Result<bool> {
622 self.inner.contains_node(id)
623 }
624
625 fn list_nodes(&self) -> Result<Vec<(NodeId, u64)>> {
626 self.inner.list_nodes()
627 }
628}
629
630pub fn observe(
637 store: &mut FieldStore,
638 id: &FieldId,
639 req: &ObserveRequest,
640 limits: Limits,
641) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
642 let started = Instant::now();
643 match narrow_probe(store, id, req)? {
644 NarrowProbe::Probed {
649 hit: true,
650 manifest,
651 carry,
652 } => {
653 let view = FieldView {
654 manifest: manifest.as_ref(),
655 id: *id,
656 open_io: IoSnapshot::default(),
657 source: &NO_SOURCE,
658 loader: None,
659 object_count: 0,
660 graph_ops: 0,
661 read_mode: DescriptorReadMode::Partial,
662 };
663 let (seeds, istore) = open_sub_stores(store)?;
664 observe_with_stores_pre(store, view, req, limits, started, seeds, istore, carry)
665 }
666 NarrowProbe::Probed {
671 hit: false,
672 manifest,
673 carry,
674 } => {
675 let opened = OpenedField::open_with_manifest(store, req, *manifest, limits)?;
676 observe_view_pre(store, opened.view(), req, limits, started, carry)
677 }
678 NarrowProbe::NotEligible => {
679 let opened = OpenedField::open(store, id, req, limits)?;
680 observe_view(store, opened.view(), req, limits, started)
681 }
682 }
683}
684
685struct NoSource;
690
691static NO_SOURCE: NoSource = NoSource;
692
693impl SourceServer for NoSource {
694 fn serve_range(&self, _offset: u64, _len: u64, _limits: Limits) -> Result<Vec<u8>> {
695 Err(Error::internal_invariant(
696 "a cache-served observation attempted a descriptor range read",
697 ))
698 }
699
700 fn serve_document(&self, _limits: Limits) -> Result<Vec<u8>> {
701 Err(Error::internal_invariant(
702 "a cache-served observation attempted a descriptor document read",
703 ))
704 }
705}
706
707#[derive(Default)]
710struct PrefetchedIndex {
711 entries: Vec<(SelectorKey, Vec<IndexEntry>)>,
712}
713
714impl PrefetchedIndex {
715 fn insert(&mut self, key: SelectorKey, entries: Vec<IndexEntry>) {
716 self.entries.push((key, entries));
717 }
718
719 fn get(&self, key: &SelectorKey) -> Option<&Vec<IndexEntry>> {
720 self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
721 }
722}
723
724#[derive(Default)]
727struct ProbeCarry {
728 base_io: IoSnapshot,
730 prefetched: PrefetchedIndex,
732 output: Option<(NodeId, Vec<u8>)>,
735}
736
737enum NarrowProbe {
739 NotEligible,
741 Probed {
744 hit: bool,
745 manifest: Box<FieldRoot>,
746 carry: ProbeCarry,
747 },
748}
749
750fn narrow_probe(store: &FieldStore, id: &FieldId, req: &ObserveRequest) -> Result<NarrowProbe> {
761 use Representation as R;
762 if !req.use_cache {
763 return Ok(NarrowProbe::NotEligible);
764 }
765 if !store.supports_partial_descriptor() {
769 return Ok(NarrowProbe::NotEligible);
770 }
771 let cacheable = matches!(
772 (&req.selector, req.representation),
773 (Selector::Page(_), R::Text | R::Preview | R::Structure)
774 | (Selector::Stream(_), R::DecodedBytes | R::Operators)
775 );
776 if !cacheable {
777 return Ok(NarrowProbe::NotEligible);
778 }
779
780 let io_before = store.io().snapshot();
781 let manifest = store.get_field(id)?;
782 let mut prefetched = PrefetchedIndex::default();
783 if !manifest.has_index() {
784 let base_io = io_before.delta(&store.io().snapshot());
786 return Ok(NarrowProbe::Probed {
787 hit: false,
788 manifest: Box::new(manifest),
789 carry: ProbeCarry {
790 base_io,
791 prefetched,
792 output: None,
793 },
794 });
795 }
796
797 let istore = FsIndexStore::open_with_io(store.root(), store.io().handle())?;
798 let root = NodeId::from_bytes(manifest.index_root);
799 let seeds = store.seed_substrate();
800
801 let target: Option<(NodeId, u64)> = match (&req.selector, req.representation) {
803 (Selector::Page(page), R::Text | R::Preview | R::Structure) => {
804 let key = SelectorKey::new(SEL_PAGE, *page);
805 let entries = lookup(&istore, &root, &key)?;
806 prefetched.insert(key, entries.clone());
807 match entries.first() {
808 Some(entry) => {
809 let (ops, text, preview) = derived_nodes(*page, entry.node_id);
810 if seeds.contains_node(&ops.content_id())?
814 && seeds.contains_node(&text.content_id())?
815 && seeds.contains_node(&preview.content_id())?
816 {
817 let node = match req.representation {
818 R::Preview | R::Structure => preview,
819 _ => text,
820 };
821 Some((node.content_id(), node.limits.max_output_bytes))
822 } else {
823 None
824 }
825 }
826 None => None,
827 }
828 }
829 (Selector::Stream(object), R::DecodedBytes | R::Operators) => {
830 let enc_key = SelectorKey::new(SEL_STREAM, *object);
831 let enc = lookup(&istore, &root, &enc_key)?;
832 prefetched.insert(enc_key, enc.clone());
833 let dec_key = SelectorKey::new(SEL_STREAM_DECODED, *object);
834 let dec = lookup(&istore, &root, &dec_key)?;
835 prefetched.insert(dec_key, dec.clone());
836 if enc.is_empty() {
840 None
841 } else {
842 match dec.first() {
843 Some(entry) if req.representation == R::DecodedBytes => Some((
846 entry.node_id,
847 crate::field::node::NodeLimits::DEFAULT.max_output_bytes,
848 )),
849 Some(entry) => {
850 let node = SeedNode::new(
851 NodeKind::ContentOperators,
852 0,
853 Vec::new(),
854 vec![entry.node_id],
855 "pdf:content-operators",
856 );
857 Some((node.content_id(), node.limits.max_output_bytes))
858 }
859 None => None,
860 }
861 }
862 }
863 _ => None,
864 };
865
866 let (hit, output) = match target {
869 Some((target_id, max_output_bytes)) => {
872 match DerivedCache::open(store.root().join("cache"))?.get(&target_id) {
873 Ok(Some(bytes)) if bytes.len() as u64 <= max_output_bytes => {
874 (true, Some((target_id, bytes)))
875 }
876 _ => (false, None),
877 }
878 }
879 None => (false, None),
880 };
881 let base_io = io_before.delta(&store.io().snapshot());
882 Ok(NarrowProbe::Probed {
883 hit,
884 manifest: Box::new(manifest),
885 carry: ProbeCarry {
886 base_io,
887 prefetched,
888 output,
889 },
890 })
891}
892
893pub(crate) fn observe_opened(
896 store: &mut FieldStore,
897 opened: &OpenedField,
898 req: &ObserveRequest,
899 limits: Limits,
900) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
901 let started = Instant::now();
902 observe_view(store, opened.view(), req, limits, started)
903}
904
905pub fn observe_with_field(
913 store: &mut FieldStore,
914 field: &Field,
915 req: &ObserveRequest,
916 limits: Limits,
917) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
918 let started = Instant::now();
919 observe_view(store, FieldView::from_field(field), req, limits, started)
920}
921
922pub(crate) enum OpenedField {
926 Full(Box<Field>),
928 Partial(Box<PartialField>),
930}
931
932impl OpenedField {
933 pub(crate) fn open(
935 store: &FieldStore,
936 id: &FieldId,
937 req: &ObserveRequest,
938 limits: Limits,
939 ) -> Result<OpenedField> {
940 if store.supports_partial_descriptor()
941 && partial_eligible(req)
942 && let Some(pf) = PartialField::try_open(store, id, limits)?
943 {
944 return Ok(OpenedField::Partial(Box::new(pf)));
945 }
946 Ok(OpenedField::Full(Box::new(Field::open(store, id, limits)?)))
947 }
948
949 pub(crate) fn open_with_manifest(
953 store: &FieldStore,
954 req: &ObserveRequest,
955 manifest: FieldRoot,
956 limits: Limits,
957 ) -> Result<OpenedField> {
958 if store.supports_partial_descriptor()
959 && partial_eligible(req)
960 && let Some(pf) =
961 PartialField::finish_open(store, manifest.clone(), store.io().snapshot(), limits)?
962 {
963 return Ok(OpenedField::Partial(Box::new(pf)));
964 }
965 Ok(OpenedField::Full(Box::new(Field::open_after_manifest(
966 store,
967 manifest,
968 store.io().snapshot(),
969 limits,
970 )?)))
971 }
972
973 pub(crate) fn view(&self) -> FieldView<'_> {
975 match self {
976 OpenedField::Full(f) => FieldView::from_field(f),
977 OpenedField::Partial(p) => p.view(),
978 }
979 }
980
981 pub(crate) fn manifest(&self) -> &FieldRoot {
983 match self {
984 OpenedField::Full(f) => f.manifest(),
985 OpenedField::Partial(p) => &p.manifest,
986 }
987 }
988}
989
990pub(crate) struct FieldView<'a> {
993 pub manifest: &'a FieldRoot,
994 pub id: FieldId,
995 pub open_io: IoSnapshot,
996 pub source: &'a dyn SourceServer,
997 pub loader: Option<&'a PartialDescriptor>,
1000 pub object_count: usize,
1001 pub graph_ops: usize,
1002 pub read_mode: DescriptorReadMode,
1003}
1004
1005impl<'a> FieldView<'a> {
1006 pub(crate) fn from_field(field: &'a Field) -> FieldView<'a> {
1007 let parsed = field.parsed();
1008 FieldView {
1009 manifest: field.manifest(),
1010 id: field.id(),
1011 open_io: field.open_io(),
1012 source: parsed,
1013 loader: None,
1014 object_count: parsed.descriptor.objects.len(),
1015 graph_ops: parsed.descriptor.program.ops.len(),
1016 read_mode: DescriptorReadMode::Full,
1017 }
1018 }
1019}
1020
1021pub(crate) struct PartialField {
1023 pub(crate) manifest: FieldRoot,
1024 pub(crate) id: FieldId,
1025 pub(crate) open_io: IoSnapshot,
1026 loader: PartialDescriptor,
1027}
1028
1029impl PartialField {
1030 pub(crate) fn try_open(
1034 store: &FieldStore,
1035 id: &FieldId,
1036 limits: Limits,
1037 ) -> Result<Option<PartialField>> {
1038 let io_before = store.io().snapshot();
1039 let manifest = store.get_field(id)?;
1040 PartialField::finish_open(store, manifest, io_before, limits)
1041 }
1042
1043 pub(crate) fn finish_open(
1050 store: &FieldStore,
1051 manifest: FieldRoot,
1052 io_before: IoSnapshot,
1053 limits: Limits,
1054 ) -> Result<Option<PartialField>> {
1055 let descriptor_id = Id::from_bytes(manifest.descriptor_id);
1056 let Some(path) = store.descriptor_path(&descriptor_id) else {
1057 return Ok(None);
1061 };
1062 let loader = match PartialDescriptor::open(&path, limits)? {
1063 PartialLoad::Ready(l) => l,
1064 PartialLoad::Ineligible { bytes_read } => {
1065 store.io().add_descriptor(bytes_read);
1068 return Ok(None);
1069 }
1070 };
1071 if loader.source_len() != manifest.source_len
1072 || loader.source_sha256() != manifest.source_sha256
1073 {
1074 return Err(Error::integrity_mismatch(
1075 "partial descriptor does not match its field manifest's declared source",
1076 ));
1077 }
1078 let open_io = io_before.delta(&store.io().snapshot());
1082 let id = manifest.content_id();
1083 Ok(Some(PartialField {
1084 id,
1085 manifest,
1086 open_io,
1087 loader: *loader,
1088 }))
1089 }
1090
1091 pub(crate) fn view(&self) -> FieldView<'_> {
1092 FieldView {
1093 manifest: &self.manifest,
1094 id: self.id,
1095 open_io: self.open_io,
1096 source: &self.loader,
1097 loader: Some(&self.loader),
1098 object_count: self.loader.object_count(),
1099 graph_ops: self.loader.graph_ops(),
1100 read_mode: DescriptorReadMode::Partial,
1101 }
1102 }
1103}
1104
1105fn partial_eligible(req: &ObserveRequest) -> bool {
1107 use Representation as R;
1108 matches!(
1109 (&req.selector, req.representation),
1110 (Selector::ByteRange { .. }, R::ExactBytes)
1111 | (Selector::Object(_), R::ExactBytes | R::EncodedBytes)
1112 | (Selector::Revision(_), R::ExactBytes)
1113 | (Selector::Stream(_), R::EncodedBytes)
1114 | (Selector::Member(_), R::EncodedBytes | R::DecodedBytes)
1115 | (Selector::Page(_), R::Text | R::Preview | R::Structure)
1116 )
1117}
1118
1119fn open_sub_stores(store: &FieldStore) -> Result<(CountingSeedStore<SeedSubstrate>, FsIndexStore)> {
1121 let io = store.io();
1122 let seeds = CountingSeedStore::new(store.seed_substrate());
1123 let istore = FsIndexStore::open_with_io(store.root(), io.handle())?;
1124 Ok((seeds, istore))
1125}
1126
1127fn observe_view<'a>(
1128 store: &'a mut FieldStore,
1129 view: FieldView<'a>,
1130 req: &ObserveRequest,
1131 limits: Limits,
1132 started: Instant,
1133) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1134 observe_view_pre(store, view, req, limits, started, ProbeCarry::default())
1135}
1136
1137fn observe_view_pre<'a>(
1141 store: &'a mut FieldStore,
1142 view: FieldView<'a>,
1143 req: &ObserveRequest,
1144 limits: Limits,
1145 started: Instant,
1146 carry: ProbeCarry,
1147) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1148 let (seeds, istore) = open_sub_stores(store)?;
1149 observe_with_stores_pre(store, view, req, limits, started, seeds, istore, carry)
1150}
1151
1152#[cfg(test)]
1155fn observe_with_stores<'a, S: SeedStore>(
1156 store: &'a mut FieldStore,
1157 view: FieldView<'a>,
1158 req: &ObserveRequest,
1159 limits: Limits,
1160 started: Instant,
1161 seeds: CountingSeedStore<S>,
1162 istore: FsIndexStore,
1163) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1164 observe_with_stores_pre(
1165 store,
1166 view,
1167 req,
1168 limits,
1169 started,
1170 seeds,
1171 istore,
1172 ProbeCarry::default(),
1173 )
1174}
1175
1176#[allow(clippy::too_many_arguments)]
1180fn observe_with_stores_pre<'a, S: SeedStore>(
1181 store: &'a mut FieldStore,
1182 view: FieldView<'a>,
1183 req: &ObserveRequest,
1184 limits: Limits,
1185 started: Instant,
1186 seeds: CountingSeedStore<S>,
1187 istore: FsIndexStore,
1188 carry: ProbeCarry,
1189) -> Result<(FieldAnswer, ObserveStats, FieldId)> {
1190 let ProbeCarry {
1191 base_io,
1192 prefetched,
1193 output,
1194 } = carry;
1195 let io_base = store.io().snapshot();
1199 let budget = EvalBudget {
1200 max_nodes: req.budget.max_nodes,
1201 ..EvalBudget::default()
1202 };
1203 let cache = DerivedCache::open(store.root().join("cache"))?;
1204 let field_id = view.id;
1205 let mut ctx = Ctx {
1206 store,
1207 manifest: view.manifest,
1208 source: view.source,
1209 loader: view.loader,
1210 open_io: view.open_io,
1211 object_count: view.object_count,
1212 graph_ops: view.graph_ops,
1213 read_mode: view.read_mode,
1214 seeds,
1215 istore,
1216 prefetched,
1217 prefetched_output: output,
1218 limits,
1219 budget,
1220 stats: ObserveStats::default(),
1221 use_cache: req.use_cache,
1222 cache,
1223 reuse: ReuseStats::default(),
1224 current_id: field_id,
1225 };
1226
1227 let answer = ctx.dispatch(req)?;
1228 let produced = answer.value.byte_len();
1229 if produced > req.budget.max_output_bytes {
1230 return Err(Error::resource_limit(format!(
1231 "observation produced {produced} bytes, exceeding the {}-byte budget",
1232 req.budget.max_output_bytes
1233 )));
1234 }
1235
1236 let mut stats = ctx.stats;
1237 if let Some(loader) = ctx.loader {
1241 ctx.store.io().add_descriptor(loader.bytes_read());
1242 }
1243 let open = ctx.open_io;
1247 let extra = io_base.delta(&ctx.store.io().snapshot());
1248 stats.descriptor_bytes_read = base_io
1249 .descriptor_bytes
1250 .saturating_add(open.descriptor_bytes)
1251 .saturating_add(extra.descriptor_bytes);
1252 stats.descriptor_read_mode = ctx.read_mode;
1253 stats.manifest_bytes_read = base_io
1254 .manifest_bytes
1255 .saturating_add(open.manifest_bytes)
1256 .saturating_add(extra.manifest_bytes);
1257 stats.index_bytes_read = base_io.index_bytes.saturating_add(extra.index_bytes);
1258 stats.seed_bytes_read = base_io.seed_bytes.saturating_add(extra.seed_bytes);
1259 stats.bytes_read = stats
1260 .descriptor_bytes_read
1261 .saturating_add(stats.manifest_bytes_read)
1262 .saturating_add(stats.index_bytes_read)
1263 .saturating_add(stats.seed_bytes_read);
1264 stats.seed_nodes_fetched = ctx.seeds.gets();
1265 stats.seed_nodes_materialized = ctx.budget.nodes;
1266 stats.seed_nodes_executed = ctx.reuse.nodes_executed;
1267 stats.seed_nodes_reused = ctx.reuse.nodes_reused;
1268 stats.cache_bytes_written = ctx.reuse.cache_bytes_written;
1269 stats.nodes_id_shared =
1272 crate::field::manifest::provenance_counter(view.manifest.provenance.as_str(), "id_shared");
1273 stats.shared_resource_ids =
1274 crate::field::manifest::provenance_counter(view.manifest.provenance.as_str(), "res_shared");
1275 stats.bytes_returned = produced;
1276 stats.wall_micros = started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64;
1277 Ok((answer, stats, ctx.current_id))
1278}
1279
1280struct Ctx<'a, S: SeedStore> {
1282 store: &'a mut FieldStore,
1283 manifest: &'a FieldRoot,
1284 source: &'a dyn SourceServer,
1285 loader: Option<&'a PartialDescriptor>,
1286 open_io: IoSnapshot,
1287 object_count: usize,
1288 graph_ops: usize,
1289 read_mode: DescriptorReadMode,
1290 seeds: CountingSeedStore<S>,
1291 istore: FsIndexStore,
1292 prefetched: PrefetchedIndex,
1294 prefetched_output: Option<(NodeId, Vec<u8>)>,
1296 limits: Limits,
1297 budget: EvalBudget,
1298 stats: ObserveStats,
1299 use_cache: bool,
1300 cache: DerivedCache,
1301 reuse: ReuseStats,
1302 current_id: FieldId,
1303}
1304
1305#[cfg(feature = "docx")]
1308struct DocxStoryView {
1309 model: StoryModel,
1310 part: DocxPartRef,
1311 deps: Vec<NodeId>,
1312 span: Option<(u64, u64)>,
1313}
1314
1315#[cfg(feature = "docx")]
1316fn opt_u8_json(v: Option<u8>) -> String {
1317 match v {
1318 Some(n) => n.to_string(),
1319 None => "null".to_string(),
1320 }
1321}
1322
1323#[cfg(feature = "docx")]
1324fn opt_str_json(v: Option<&str>) -> String {
1325 match v {
1326 Some(s) => format!("\"{}\"", json_escape(s)),
1327 None => "null".to_string(),
1328 }
1329}
1330
1331#[cfg(feature = "docx")]
1335fn parse_cell_ref(s: &str) -> Option<(u32, u32)> {
1336 let letters: String = s.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
1337 let digits: String = s.chars().skip(letters.len()).collect();
1338 if letters.is_empty() || digits.is_empty() || digits.len() != s.len() - letters.len() {
1339 return None;
1340 }
1341 if !digits.chars().all(|c| c.is_ascii_digit()) {
1342 return None;
1343 }
1344 let mut col: u32 = 0;
1345 for c in letters.chars() {
1346 let v = c.to_ascii_uppercase() as u32 - 'A' as u32 + 1;
1347 col = col.checked_mul(26)?.checked_add(v)?;
1348 }
1349 let col = col.checked_sub(1)?;
1350 let row: u32 = digits.parse().ok()?;
1351 if row == 0 {
1352 return None;
1353 }
1354 Some((col, row - 1))
1355}
1356
1357impl<S: SeedStore> Ctx<'_, S> {
1358 fn materialize(&mut self, node: &SeedNode) -> Result<Vec<u8>> {
1359 match node.kind {
1365 NodeKind::PackageOpcModel
1366 | NodeKind::DocxModel
1367 | NodeKind::DocxStory
1368 | NodeKind::EpubModel
1369 | NodeKind::EpubContent => {
1370 self.stats.xml_parses = self.stats.xml_parses.saturating_add(1);
1371 }
1372 _ => {}
1373 }
1374 let depth = node.limits.max_depth;
1375 if self.use_cache {
1376 if let Some((id, bytes)) = self.prefetched_output.take() {
1380 if id == node.content_id() {
1381 self.reuse.nodes_reused = self.reuse.nodes_reused.saturating_add(1);
1382 self.budget.charge_bytes(bytes.len() as u64)?;
1383 return Ok(bytes);
1384 }
1385 self.prefetched_output = Some((id, bytes));
1386 }
1387 dag::materialize_node_cached_with(
1388 self.source,
1389 &self.seeds,
1390 &mut self.cache,
1391 node,
1392 self.limits,
1393 &mut self.budget,
1394 depth,
1395 &mut self.reuse,
1396 )
1397 } else {
1398 let mut cache = dag::NoCache;
1399 dag::materialize_node_cached_with(
1400 self.source,
1401 &self.seeds,
1402 &mut cache,
1403 node,
1404 self.limits,
1405 &mut self.budget,
1406 depth,
1407 &mut self.reuse,
1408 )
1409 }
1410 }
1411
1412 fn load(&self, id: &NodeId) -> Result<SeedNode> {
1413 dag::load_node(&self.seeds, id)
1414 }
1415
1416 fn lookup(&mut self, key: SelectorKey) -> Result<Vec<IndexEntry>> {
1417 if !self.manifest.has_index() {
1418 return Ok(Vec::new());
1419 }
1420 let prefetched = self.prefetched.get(&key).cloned();
1425 let entries = match prefetched {
1426 Some(entries) => entries,
1427 None => {
1428 let root = NodeId::from_bytes(self.manifest.index_root);
1429 lookup(&self.istore, &root, &key)?
1430 }
1431 };
1432 self.stats.index_nodes_read += entries.len() as u64;
1433 Ok(entries)
1434 }
1435
1436 fn require_entry(&mut self, key: SelectorKey, what: &str) -> Result<IndexEntry> {
1437 let entries = self.lookup(key)?;
1438 entries.into_iter().next().ok_or_else(|| {
1439 Error::unsupported_feature(format!(
1440 "no {what} matching selector number {} in the observation index",
1441 key.number
1442 ))
1443 })
1444 }
1445
1446 fn dispatch(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1447 if req.selector.is_common() {
1450 return self.common_dispatch(req);
1451 }
1452 use Representation as R;
1453 match (&req.selector, req.representation) {
1454 (Selector::Document, R::FullDocument | R::ExactBytes) => self.document_full(req),
1455 (Selector::Document, R::Metadata) => self.document_metadata(req),
1456 (Selector::ByteRange { offset, len }, R::ExactBytes) => {
1457 self.byte_range(req, *offset, *len)
1458 }
1459 (Selector::Object(n), R::ExactBytes | R::EncodedBytes) => {
1460 self.indexed_exact(req, SelectorKey::new(SEL_OBJECT, *n), "object")
1461 }
1462 (Selector::Revision(n), R::ExactBytes) => {
1463 self.indexed_exact(req, SelectorKey::new(SEL_REVISION, *n), "revision")
1464 }
1465 (Selector::Member(n), R::EncodedBytes) => self.indexed_exact(
1466 req,
1467 SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, *n),
1468 "package member",
1469 ),
1470 (Selector::Member(n), R::DecodedBytes) => self.member_decoded(req, *n),
1471 (Selector::PackagePart(_), R::Metadata | R::ExactBytes | R::DecodedBytes) => {
1472 self.package_part_opc(req)
1473 }
1474 (Selector::Relationship(_), R::Metadata | R::ExactBytes | R::DecodedBytes) => {
1475 self.relationship_opc(req)
1476 }
1477 (Selector::Stream(n), R::EncodedBytes) => {
1478 self.indexed_exact(req, SelectorKey::new(SEL_STREAM, *n), "stream")
1479 }
1480 (Selector::Stream(n), R::DecodedBytes) => self.stream_decoded(req, *n),
1481 (Selector::Stream(n), R::Operators) => self.stream_operators(req, *n),
1482 (Selector::Page(n), R::Text) => self.page_text(req, *n),
1483 (Selector::Page(n), R::Preview) => self.page_preview(req, *n),
1484 (Selector::Page(n), R::Structure) => self.page_structure(req, *n),
1485 (Selector::TextMatch(p), R::Text) => self.text_match(req, p),
1486 #[cfg(feature = "docx")]
1487 (Selector::DocxStory { story, profile }, R::Text) => {
1488 self.docx_story_text(req, *story, profile)
1489 }
1490 #[cfg(feature = "docx")]
1491 (Selector::DocxStory { story, profile }, R::Structure) => {
1492 self.docx_story_structure(req, *story, profile)
1493 }
1494 #[cfg(feature = "docx")]
1495 (Selector::DocxStory { story, profile }, R::Metadata) => {
1496 self.docx_story_metadata(req, *story, profile)
1497 }
1498 #[cfg(feature = "docx")]
1499 (
1500 Selector::DocxParagraph {
1501 story,
1502 index,
1503 profile,
1504 },
1505 R::Text | R::Metadata,
1506 ) => self.docx_paragraph(req, *story, *index, profile),
1507 #[cfg(feature = "docx")]
1508 (
1509 Selector::DocxTable {
1510 story,
1511 index,
1512 profile,
1513 },
1514 R::Text | R::Metadata,
1515 ) => self.docx_table(req, *story, *index, profile),
1516 #[cfg(feature = "docx")]
1517 (
1518 Selector::DocxCell {
1519 story,
1520 table,
1521 cell,
1522 profile,
1523 },
1524 R::Text | R::Metadata,
1525 ) => self.docx_cell(req, *story, *table, cell, profile),
1526 #[cfg(feature = "docx")]
1527 (
1528 Selector::DocxFind {
1529 story,
1530 pattern,
1531 profile,
1532 },
1533 R::Text,
1534 ) => self.docx_find(req, *story, pattern, profile),
1535 #[cfg(feature = "epub")]
1536 (Selector::EpubPackage, R::Metadata | R::Structure) => self.epub_package(req),
1537 #[cfg(feature = "epub")]
1538 (Selector::EpubManifestItem { index }, R::Metadata) => {
1539 self.epub_manifest_item_meta(req, *index)
1540 }
1541 #[cfg(feature = "epub")]
1542 (Selector::EpubManifestItem { index }, R::ExactBytes | R::DecodedBytes) => {
1543 self.epub_manifest_item_bytes(req, *index)
1544 }
1545 #[cfg(feature = "epub")]
1546 (Selector::EpubSpineItem { index, profile }, R::Metadata) => {
1547 self.epub_spine_item_meta(req, *index, profile)
1548 }
1549 #[cfg(feature = "epub")]
1550 (Selector::EpubSpineItem { index, profile }, R::Text) => {
1551 self.epub_spine_item_text(req, *index, profile)
1552 }
1553 #[cfg(feature = "epub")]
1554 (Selector::EpubSpineItem { index, profile }, R::Structure) => {
1555 self.epub_spine_item_structure(req, *index, profile)
1556 }
1557 #[cfg(feature = "epub")]
1558 (Selector::EpubSpineItem { index, profile }, R::Preview) => {
1559 self.epub_spine_item_preview(req, *index, profile)
1560 }
1561 #[cfg(feature = "epub")]
1562 (Selector::EpubSpineItem { index, profile }, R::ExactBytes | R::DecodedBytes) => {
1563 self.epub_spine_item_bytes(req, *index, profile)
1564 }
1565 #[cfg(feature = "epub")]
1566 (Selector::EpubNav, R::Metadata | R::Structure) => self.epub_nav(req),
1567 #[cfg(feature = "epub")]
1568 (Selector::EpubNavNode { index }, R::Metadata) => self.epub_nav_node(req, *index),
1569 #[cfg(feature = "epub")]
1570 (Selector::EpubResource(name), R::Metadata) => self.epub_resource_meta(req, name),
1571 #[cfg(feature = "epub")]
1572 (Selector::EpubResource(name), R::ExactBytes | R::DecodedBytes) => {
1573 self.epub_resource_bytes(req, name)
1574 }
1575 #[cfg(feature = "epub")]
1576 (
1577 Selector::EpubBlock {
1578 index,
1579 block,
1580 profile,
1581 },
1582 R::Text | R::Metadata | R::Structure,
1583 ) => self.epub_block(req, *index, *block, profile),
1584 #[cfg(feature = "epub")]
1585 (
1586 Selector::EpubCell {
1587 index,
1588 table,
1589 row,
1590 col,
1591 profile,
1592 },
1593 R::Text | R::Metadata,
1594 ) => self.epub_cell(req, *index, *table, *row, *col, profile),
1595 #[cfg(feature = "epub")]
1596 (
1597 Selector::EpubLink {
1598 index,
1599 link,
1600 profile,
1601 },
1602 R::Metadata,
1603 ) => self.epub_link(req, *index, *link, profile),
1604 #[cfg(feature = "epub")]
1605 (
1606 Selector::EpubFind {
1607 index,
1608 pattern,
1609 profile,
1610 },
1611 R::Text,
1612 ) => self.epub_find(req, *index, pattern, profile),
1613 _ => Err(Error::unsupported_feature(format!(
1614 "unsupported observation: selector {} with representation {}",
1615 req.selector.canonical(),
1616 req.representation.name()
1617 ))),
1618 }
1619 }
1620
1621 fn document_full(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1622 let bytes = self.source.serve_document(self.limits)?;
1623 Ok(FieldAnswer {
1624 value: AnswerValue::Bytes(bytes),
1625 basis: Basis::DirectlyObserved,
1626 selector: req.selector.canonical(),
1627 representation: req.representation.name().to_string(),
1628 source_span: Some((0, self.manifest.source_len)),
1629 provenance: String::new(),
1630 dependency_ids: vec![self.manifest.root_node],
1631 integrity_scope: IntegrityScope::WholeSource,
1632 exact: true,
1633 })
1634 }
1635
1636 fn document_metadata(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1637 let json = format!(
1638 concat!(
1639 "{{",
1640 "\"source_len\":{},",
1641 "\"source_sha256\":\"{}\",",
1642 "\"object_count\":{},",
1643 "\"graph_ops\":{},",
1644 "\"node_count\":{}",
1645 "}}"
1646 ),
1647 self.manifest.source_len,
1648 crate::integrity::to_hex(&self.manifest.source_sha256),
1649 self.object_count,
1650 self.graph_ops,
1651 self.manifest.node_count,
1652 );
1653 Ok(FieldAnswer {
1654 value: AnswerValue::Json(json),
1655 basis: Basis::DeterministicallyDerived,
1656 selector: req.selector.canonical(),
1657 representation: req.representation.name().to_string(),
1658 source_span: None,
1659 provenance: String::new(),
1660 dependency_ids: Vec::new(),
1661 integrity_scope: IntegrityScope::None,
1662 exact: false,
1663 })
1664 }
1665
1666 fn byte_range(&mut self, req: &ObserveRequest, offset: u64, len: u64) -> Result<FieldAnswer> {
1667 let end = offset
1668 .checked_add(len)
1669 .ok_or_else(|| Error::usage("byte-range end overflows"))?;
1670 let node = SeedNode::new(
1671 NodeKind::SourceSlice,
1672 len,
1673 span_params(offset, len),
1674 Vec::new(),
1675 "field:observe;source-slice",
1676 );
1677 let bytes = self.materialize(&node)?;
1678 Ok(FieldAnswer {
1679 value: AnswerValue::Bytes(bytes),
1680 basis: Basis::DirectlyObserved,
1681 selector: req.selector.canonical(),
1682 representation: req.representation.name().to_string(),
1683 source_span: Some((offset, end)),
1684 provenance: String::new(),
1685 dependency_ids: Vec::new(),
1686 integrity_scope: IntegrityScope::Node,
1687 exact: true,
1688 })
1689 }
1690
1691 fn indexed_exact(
1692 &mut self,
1693 req: &ObserveRequest,
1694 key: SelectorKey,
1695 what: &str,
1696 ) -> Result<FieldAnswer> {
1697 let entry = self.require_entry(key, what)?;
1698 let node = self.load(&entry.node_id)?;
1699 let bytes = self.materialize(&node)?;
1700 let end = entry.out_off.saturating_add(entry.out_len);
1701 Ok(FieldAnswer {
1702 value: AnswerValue::Bytes(bytes),
1703 basis: Basis::DirectlyObserved,
1704 selector: req.selector.canonical(),
1705 representation: req.representation.name().to_string(),
1706 source_span: Some((entry.out_off, end)),
1707 provenance: String::new(),
1708 dependency_ids: vec![entry.node_id],
1709 integrity_scope: IntegrityScope::Node,
1710 exact: true,
1711 })
1712 }
1713
1714 fn stream_decoded(&mut self, req: &ObserveRequest, object: u32) -> Result<FieldAnswer> {
1715 let entry = self.require_entry(SelectorKey::new(SEL_STREAM, object), "stream")?;
1716 let node = self.decoded_node(object, &entry.node_id)?;
1717 let id = node.content_id();
1718 let bytes = self.materialize(&node)?;
1719 Ok(FieldAnswer {
1720 value: AnswerValue::Bytes(bytes),
1721 basis: Basis::DeterministicallyDerived,
1722 selector: req.selector.canonical(),
1723 representation: req.representation.name().to_string(),
1724 source_span: None,
1725 provenance: String::new(),
1726 dependency_ids: vec![id],
1727 integrity_scope: IntegrityScope::None,
1728 exact: false,
1729 })
1730 }
1731
1732 fn member_decoded(&mut self, req: &ObserveRequest, ordinal: u32) -> Result<FieldAnswer> {
1739 let entry = self.require_entry(
1740 SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, ordinal),
1741 "decoded package member",
1742 )?;
1743 let node = self.load(&entry.node_id)?;
1744 self.stats.member_decodes = self.stats.member_decodes.saturating_add(1);
1745 let id = node.content_id();
1746 let raw_deps = node.deps.clone();
1747 let bytes = self.materialize(&node)?;
1748 let mut dependency_ids = vec![id];
1749 dependency_ids.extend(raw_deps);
1750 Ok(FieldAnswer {
1751 value: AnswerValue::Bytes(bytes),
1752 basis: Basis::DeterministicallyDerived,
1753 selector: req.selector.canonical(),
1754 representation: req.representation.name().to_string(),
1755 source_span: None,
1756 provenance: String::new(),
1757 dependency_ids,
1758 integrity_scope: IntegrityScope::None,
1759 exact: false,
1760 })
1761 }
1762
1763 #[cfg(feature = "opc")]
1765 fn opc_model(&mut self) -> Result<crate::adapter::package::opc::OpcModel> {
1766 let entry = self.require_entry(SelectorKey::new(SEL_OPC_MODEL, 0), "OPC model")?;
1767 let node = self.load(&entry.node_id)?;
1768 let bytes = self.materialize(&node)?;
1769 crate::adapter::package::opc::OpcModel::decode(&bytes)
1770 }
1771
1772 #[cfg(feature = "opc")]
1776 fn package_part_opc(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1777 use Representation as R;
1778 let Selector::PackagePart(name) = &req.selector else {
1779 return Err(Error::internal_invariant(
1780 "package_part_opc needs PackagePart",
1781 ));
1782 };
1783 let name = name.clone();
1784 let model = self.opc_model()?;
1785 let part = model.part_by_name(&name).ok_or_else(|| {
1786 Error::invalid_package_structure(format!("no package part named {name:?}"))
1787 })?;
1788 let ordinal = part.ordinal;
1789 match req.representation {
1790 R::ExactBytes => self.indexed_exact(
1791 req,
1792 SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, ordinal),
1793 "package part",
1794 ),
1795 R::DecodedBytes => self.member_decoded(req, ordinal),
1796 R::Metadata => {
1797 let rel_count = model
1798 .part_rels
1799 .iter()
1800 .find(|(o, _)| *o == ordinal)
1801 .map_or(0, |(_, r)| r.len());
1802 let ct = match &part.content_type {
1803 Some(c) => format!("\"{}\"", json_escape(c)),
1804 None => "null".to_string(),
1805 };
1806 let json = format!(
1807 "{{\"name\":\"{}\",\"ordinal\":{},\"content_type\":{},\"relationships\":{}}}",
1808 json_escape(&part.name),
1809 ordinal,
1810 ct,
1811 rel_count
1812 );
1813 Ok(FieldAnswer {
1814 value: AnswerValue::Json(json),
1815 basis: Basis::DeterministicallyDerived,
1816 selector: req.selector.canonical(),
1817 representation: req.representation.name().to_string(),
1818 source_span: None,
1819 provenance: String::new(),
1820 dependency_ids: Vec::new(),
1821 integrity_scope: IntegrityScope::None,
1822 exact: false,
1823 })
1824 }
1825 _ => Err(Error::unsupported_feature(format!(
1826 "unsupported observation: selector {} with representation {}",
1827 req.selector.canonical(),
1828 req.representation.name()
1829 ))),
1830 }
1831 }
1832
1833 #[cfg(feature = "opc")]
1838 fn relationship_opc(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
1839 use Representation as R;
1840 let Selector::Relationship(id) = &req.selector else {
1841 return Err(Error::internal_invariant(
1842 "relationship_opc needs Relationship",
1843 ));
1844 };
1845 let id = id.clone();
1846 let model = self.opc_model()?;
1847 let (rel, owner) = model.relationship_by_id(&id)?.ok_or_else(|| {
1848 Error::invalid_package_structure(format!("no package relationship with id {id:?}"))
1849 })?;
1850 match req.representation {
1851 R::Metadata => {
1852 let resolved = match &rel.resolved {
1853 Some(r) => format!("\"{}\"", json_escape(r)),
1854 None => "null".to_string(),
1855 };
1856 let owner_json = match owner {
1857 Some(o) => o.to_string(),
1858 None => "null".to_string(),
1859 };
1860 let json = format!(
1861 concat!(
1862 "{{\"id\":\"{}\",\"type\":\"{}\",\"target\":\"{}\",",
1863 "\"target_mode\":\"{}\",\"resolved\":{},\"owner\":{}}}"
1864 ),
1865 json_escape(&rel.id),
1866 json_escape(&rel.rel_type),
1867 json_escape(&rel.target),
1868 rel.mode.name(),
1869 resolved,
1870 owner_json
1871 );
1872 Ok(FieldAnswer {
1873 value: AnswerValue::Json(json),
1874 basis: Basis::DeterministicallyDerived,
1875 selector: req.selector.canonical(),
1876 representation: req.representation.name().to_string(),
1877 source_span: None,
1878 provenance: String::new(),
1879 dependency_ids: Vec::new(),
1880 integrity_scope: IntegrityScope::None,
1881 exact: false,
1882 })
1883 }
1884 R::ExactBytes | R::DecodedBytes => {
1885 let resolved = rel.resolved.clone().ok_or_else(|| {
1886 Error::invalid_package_structure(format!(
1887 "relationship {id:?} is external: its target is an inert identifier, never fetched"
1888 ))
1889 })?;
1890 let part = model.part_by_name(&resolved).ok_or_else(|| {
1891 Error::invalid_package_structure(format!(
1892 "relationship {id:?} target {resolved:?} is not a package part"
1893 ))
1894 })?;
1895 let ordinal = part.ordinal;
1896 if req.representation == R::ExactBytes {
1897 self.indexed_exact(
1898 req,
1899 SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, ordinal),
1900 "relationship target part",
1901 )
1902 } else {
1903 self.member_decoded(req, ordinal)
1904 }
1905 }
1906 _ => Err(Error::unsupported_feature(format!(
1907 "unsupported observation: selector {} with representation {}",
1908 req.selector.canonical(),
1909 req.representation.name()
1910 ))),
1911 }
1912 }
1913
1914 #[cfg(not(feature = "opc"))]
1916 fn package_part_opc(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
1917 Err(Error::unsupported_feature(
1918 "OPC support is not compiled in (feature `opc`)",
1919 ))
1920 }
1921
1922 #[cfg(not(feature = "opc"))]
1924 fn relationship_opc(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
1925 Err(Error::unsupported_feature(
1926 "OPC support is not compiled in (feature `opc`)",
1927 ))
1928 }
1929
1930 fn stream_operators(&mut self, req: &ObserveRequest, object: u32) -> Result<FieldAnswer> {
1931 let entry = self.require_entry(SelectorKey::new(SEL_STREAM, object), "stream")?;
1932 let decoded = self.decoded_node(object, &entry.node_id)?;
1933 let decoded_id = decoded.content_id();
1934 let node = SeedNode::new(
1935 NodeKind::ContentOperators,
1936 0,
1937 Vec::new(),
1938 vec![decoded_id],
1939 "pdf:content-operators",
1940 );
1941 let id = node.content_id();
1942 let bytes = self.materialize(&node)?;
1943 Ok(FieldAnswer {
1944 value: AnswerValue::Bytes(bytes),
1945 basis: Basis::DeterministicallyDerived,
1946 selector: req.selector.canonical(),
1947 representation: req.representation.name().to_string(),
1948 source_span: None,
1949 provenance: String::new(),
1950 dependency_ids: vec![id, decoded_id],
1951 integrity_scope: IntegrityScope::None,
1952 exact: false,
1953 })
1954 }
1955
1956 fn page_content_id(&mut self, what_number: u32) -> Result<NodeId> {
1957 Ok(self
1958 .require_entry(SelectorKey::new(SEL_PAGE, what_number), "page")?
1959 .node_id)
1960 }
1961
1962 fn ensure_page_derived(
1970 &mut self,
1971 page: u32,
1972 page_content: NodeId,
1973 ) -> Result<(SeedNode, SeedNode, SeedNode)> {
1974 let (ops, text, preview) = derived_nodes(page, page_content);
1975 let present = self.seeds.contains_node(&ops.content_id())?
1976 && self.seeds.contains_node(&text.content_id())?
1977 && self.seeds.contains_node(&preview.content_id())?;
1978 if !present {
1979 let promoted = ingest::deepen_page_with_manifest(self.store, self.manifest, page)?;
1982 self.stats.deepened = true;
1983 self.current_id = promoted;
1984 }
1985 Ok((ops, text, preview))
1986 }
1987
1988 fn page_text(&mut self, req: &ObserveRequest, page: u32) -> Result<FieldAnswer> {
1989 let pc = self.page_content_id(page)?;
1990 let (ops, text, _preview) = self.ensure_page_derived(page, pc)?;
1991 let text_id = text.content_id();
1992 let bytes = self.materialize(&text)?;
1993 let value = AnswerValue::Text(String::from_utf8_lossy(&bytes).into_owned());
1994 Ok(FieldAnswer {
1995 value,
1996 basis: Basis::Heuristic,
1997 selector: req.selector.canonical(),
1998 representation: req.representation.name().to_string(),
1999 source_span: None,
2000 provenance: String::new(),
2001 dependency_ids: vec![text_id, ops.content_id(), pc],
2002 integrity_scope: IntegrityScope::None,
2003 exact: false,
2004 })
2005 }
2006
2007 fn page_preview(&mut self, req: &ObserveRequest, page: u32) -> Result<FieldAnswer> {
2008 let pc = self.page_content_id(page)?;
2009 let (_ops, _text, preview) = self.ensure_page_derived(page, pc)?;
2010 let preview_id = preview.content_id();
2011 let bytes = self.materialize(&preview)?;
2012 Ok(FieldAnswer {
2013 value: AnswerValue::Bytes(bytes),
2014 basis: Basis::Heuristic,
2015 selector: req.selector.canonical(),
2016 representation: req.representation.name().to_string(),
2017 source_span: None,
2018 provenance: String::new(),
2019 dependency_ids: vec![preview_id, pc],
2020 integrity_scope: IntegrityScope::None,
2021 exact: false,
2022 })
2023 }
2024
2025 fn page_structure(&mut self, req: &ObserveRequest, page: u32) -> Result<FieldAnswer> {
2028 let pc = self.page_content_id(page)?;
2029 let (_ops, _text, preview) = self.ensure_page_derived(page, pc)?;
2030 let preview_id = preview.content_id();
2031 let preview_bytes = self.materialize(&preview)?;
2032 let (text_bytes, draw_ops, path_ops) = preview_stats(&preview_bytes);
2033
2034 let pc_node = self.load(&pc)?;
2037 let mut streams: Vec<u32> = Vec::new();
2038 for dep in &pc_node.deps {
2039 let dep_node = self.load(dep)?;
2040 if !matches!(
2044 dep_node.kind,
2045 NodeKind::PdfStreamDecoded | NodeKind::PdfStreamEncoded
2046 ) {
2047 continue;
2048 }
2049 if let Ok(object) = read_u32_params(&dep_node.params) {
2050 streams.push(object);
2051 }
2052 }
2053 let streams_json = streams
2054 .iter()
2055 .map(u32::to_string)
2056 .collect::<Vec<_>>()
2057 .join(",");
2058
2059 let json = format!(
2060 "{{\"page\":{page},\"text_bytes\":{text_bytes},\"draw_ops\":{draw_ops},\"path_ops\":{path_ops},\"content_streams\":[{streams_json}]}}"
2061 );
2062 Ok(FieldAnswer {
2063 value: AnswerValue::Json(json),
2064 basis: Basis::DeterministicallyDerived,
2065 selector: req.selector.canonical(),
2066 representation: req.representation.name().to_string(),
2067 source_span: None,
2068 provenance: String::new(),
2069 dependency_ids: vec![preview_id, pc],
2070 integrity_scope: IntegrityScope::None,
2071 exact: false,
2072 })
2073 }
2074
2075 fn text_match(&mut self, req: &ObserveRequest, pattern: &str) -> Result<FieldAnswer> {
2076 let mut items: Vec<(u32, String)> = Vec::new();
2077 let mut estimated: u64 = 0;
2078 let mut page: u32 = 1;
2079 while page <= MAX_TEXTMATCH_PAGES {
2080 let entries = self.lookup(SelectorKey::new(SEL_PAGE, page))?;
2081 let Some(entry) = entries.into_iter().next() else {
2082 break;
2083 };
2084 let pc = entry.node_id;
2085 let (_ops, text, _preview) = self.ensure_page_derived(page, pc)?;
2086 let bytes = self.materialize(&text)?;
2087 let rendered = String::from_utf8_lossy(&bytes).into_owned();
2088 for line in rendered.split('\n') {
2089 if line.contains(pattern) {
2090 estimated = estimated.saturating_add(line.len() as u64 + 32);
2091 if estimated > req.budget.max_output_bytes {
2092 return Err(Error::resource_limit(format!(
2093 "text match exceeded the {}-byte budget",
2094 req.budget.max_output_bytes
2095 )));
2096 }
2097 items.push((page, line.to_string()));
2098 }
2099 }
2100 page += 1;
2101 }
2102 let body = items
2103 .iter()
2104 .map(|(p, line)| format!("{{\"page\":{p},\"line\":\"{}\"}}", json_escape(line)))
2105 .collect::<Vec<_>>()
2106 .join(",");
2107 Ok(FieldAnswer {
2108 value: AnswerValue::Json(format!("[{body}]")),
2109 basis: Basis::Heuristic,
2110 selector: req.selector.canonical(),
2111 representation: req.representation.name().to_string(),
2112 source_span: None,
2113 provenance: String::new(),
2114 dependency_ids: Vec::new(),
2115 integrity_scope: IntegrityScope::None,
2116 exact: false,
2117 })
2118 }
2119
2120 #[cfg(feature = "docx")]
2124 fn docx_model(&mut self) -> Result<DocxModel> {
2125 let entry = self.require_entry(SelectorKey::new(SEL_DOCX_MODEL, 0), "DOCX model")?;
2126 let node = self.load(&entry.node_id)?;
2127 let bytes = self.materialize(&node)?;
2128 DocxModel::decode(&bytes)
2129 }
2130
2131 #[cfg(feature = "docx")]
2135 fn docx_story_view(
2136 &mut self,
2137 story: DocxStory,
2138 profile: &DocxExtractProfile,
2139 ) -> Result<DocxStoryView> {
2140 if story.kind_index().is_none() {
2141 return Err(Error::unsupported_feature(format!(
2142 "DOCX story {} is declared but not part-backed; preserved exactly, not interpreted",
2143 story.name()
2144 )));
2145 }
2146 let model = self.docx_model()?;
2147 let part = model.story_part(story).cloned().ok_or_else(|| {
2148 Error::unsupported_feature(format!(
2149 "DOCX package has no part for story {}",
2150 story.name()
2151 ))
2152 })?;
2153 let dec = self.require_entry(
2154 SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, part.ordinal),
2155 "DOCX story part decoded bytes",
2156 )?;
2157 let mut deps = vec![dec.node_id];
2158 if let Some(styles) = &model.styles
2159 && let Ok(e) = self.require_entry(
2160 SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, styles.ordinal),
2161 "DOCX styles decoded bytes",
2162 )
2163 {
2164 deps.push(e.node_id);
2165 }
2166 self.stats.member_decodes = self.stats.member_decodes.saturating_add(deps.len() as u64);
2169 let span = self
2170 .lookup(SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, part.ordinal))?
2171 .into_iter()
2172 .next()
2173 .map(|e| (e.out_off, e.out_off.saturating_add(e.out_len)));
2174 let mut node = SeedNode::new(
2175 NodeKind::DocxStory,
2176 self.limits.max_output_bytes,
2177 story_params(story, &part.name, profile),
2178 deps.clone(),
2179 "docx:story",
2180 );
2181 node.limits.max_output_bytes = self.limits.max_output_bytes;
2182 let id = node.content_id();
2183 let bytes = self.materialize(&node)?;
2184 let sm = StoryModel::decode(&bytes)?;
2185 let mut ids = vec![id];
2186 ids.extend(deps);
2187 Ok(DocxStoryView {
2188 model: sm,
2189 part,
2190 deps: ids,
2191 span,
2192 })
2193 }
2194
2195 #[cfg(feature = "docx")]
2196 fn docx_answer(
2197 &self,
2198 req: &ObserveRequest,
2199 value: AnswerValue,
2200 provenance: String,
2201 span: Option<(u64, u64)>,
2202 deps: Vec<NodeId>,
2203 ) -> FieldAnswer {
2204 FieldAnswer {
2205 value,
2206 basis: Basis::DeterministicallyDerived,
2207 selector: req.selector.canonical(),
2208 representation: req.representation.name().to_string(),
2209 source_span: span,
2210 provenance,
2211 dependency_ids: deps,
2212 integrity_scope: IntegrityScope::None,
2213 exact: false,
2214 }
2215 }
2216
2217 #[cfg(feature = "docx")]
2218 fn docx_story_text(
2219 &mut self,
2220 req: &ObserveRequest,
2221 story: DocxStory,
2222 profile: &DocxExtractProfile,
2223 ) -> Result<FieldAnswer> {
2224 let v = self.docx_story_view(story, profile)?;
2225 let text = v.model.text();
2226 let provenance = format!(
2227 "docx;story={};part={};profile={}",
2228 story.name(),
2229 v.part.name,
2230 profile.fingerprint()
2231 );
2232 Ok(self.docx_answer(req, AnswerValue::Text(text), provenance, v.span, v.deps))
2233 }
2234
2235 #[cfg(feature = "docx")]
2236 fn docx_story_metadata(
2237 &mut self,
2238 req: &ObserveRequest,
2239 story: DocxStory,
2240 profile: &DocxExtractProfile,
2241 ) -> Result<FieldAnswer> {
2242 let v = self.docx_story_view(story, profile)?;
2243 let json = format!(
2244 concat!(
2245 "{{\"story\":\"{}\",\"part\":\"{}\",\"ordinal\":{},\"root\":\"{}\",",
2246 "\"paragraphs\":{},\"tables\":{},\"hyperlinks\":{},\"bookmarks\":{},",
2247 "\"resources\":{},\"sections\":{},\"profile\":\"{}\"}}"
2248 ),
2249 json_escape(&story.name()),
2250 json_escape(&v.part.name),
2251 v.part.ordinal,
2252 json_escape(&v.model.root_local),
2253 v.model.paragraphs().count(),
2254 v.model.tables().count(),
2255 v.model.hyperlinks.len(),
2256 v.model.bookmarks.len(),
2257 v.model.resources.len(),
2258 v.model.section_count,
2259 profile.fingerprint(),
2260 );
2261 let provenance = format!("docx;story={};part={}", story.name(), v.part.name);
2262 Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
2263 }
2264
2265 #[cfg(feature = "docx")]
2266 fn docx_story_structure(
2267 &mut self,
2268 req: &ObserveRequest,
2269 story: DocxStory,
2270 profile: &DocxExtractProfile,
2271 ) -> Result<FieldAnswer> {
2272 let v = self.docx_story_view(story, profile)?;
2273 let paras = v
2274 .model
2275 .paragraphs()
2276 .map(|p| {
2277 format!(
2278 "{{\"index\":{},\"heading\":{},\"style\":{},\"text_len\":{}}}",
2279 p.index,
2280 opt_u8_json(p.heading_level),
2281 opt_str_json(p.style_id.as_deref()),
2282 p.text.len()
2283 )
2284 })
2285 .collect::<Vec<_>>()
2286 .join(",");
2287 let tables = v
2288 .model
2289 .tables()
2290 .map(|t| {
2291 format!(
2292 "{{\"index\":{},\"rows\":{},\"cols_row0\":{}}}",
2293 t.index,
2294 t.rows.len(),
2295 t.rows.first().map_or(0, |r| r.cells.len())
2296 )
2297 })
2298 .collect::<Vec<_>>()
2299 .join(",");
2300 let json = format!(
2301 concat!(
2302 "{{\"story\":\"{}\",\"part\":\"{}\",\"blocks\":{},",
2303 "\"paragraphs\":[{}],\"tables\":[{}],\"profile\":\"{}\"}}"
2304 ),
2305 json_escape(&story.name()),
2306 json_escape(&v.part.name),
2307 v.model.blocks.len(),
2308 paras,
2309 tables,
2310 profile.fingerprint(),
2311 );
2312 Ok(self.docx_answer(
2313 req,
2314 AnswerValue::Json(json),
2315 format!("docx;story={};part={}", story.name(), v.part.name),
2316 v.span,
2317 v.deps,
2318 ))
2319 }
2320
2321 #[cfg(feature = "docx")]
2322 fn docx_paragraph(
2323 &mut self,
2324 req: &ObserveRequest,
2325 story: DocxStory,
2326 index: u32,
2327 profile: &DocxExtractProfile,
2328 ) -> Result<FieldAnswer> {
2329 let v = self.docx_story_view(story, profile)?;
2330 let p = v
2331 .model
2332 .paragraphs()
2333 .find(|p| p.index == index)
2334 .ok_or_else(|| {
2335 Error::unsupported_feature(format!(
2336 "DOCX story {} has no body paragraph {index}",
2337 story.name()
2338 ))
2339 })?;
2340 let text = p.text.clone();
2341 let style = p.style_id.clone();
2342 let heading = p.heading_level;
2343 let run_count = p.runs.len();
2344 let provenance = format!(
2345 "docx;story={};part={};paragraph={};profile={}",
2346 story.name(),
2347 v.part.name,
2348 index,
2349 profile.fingerprint()
2350 );
2351 let value = match req.representation {
2352 Representation::Text => AnswerValue::Text(text),
2353 Representation::Metadata => AnswerValue::Json(format!(
2354 concat!(
2355 "{{\"story\":\"{}\",\"part\":\"{}\",\"paragraph\":{},",
2356 "\"style\":{},\"heading\":{},\"runs\":{},\"text_len\":{}}}"
2357 ),
2358 json_escape(&story.name()),
2359 json_escape(&v.part.name),
2360 index,
2361 opt_str_json(style.as_deref()),
2362 opt_u8_json(heading),
2363 run_count,
2364 text.len(),
2365 )),
2366 _ => {
2367 return Err(Error::unsupported_feature(format!(
2368 "unsupported observation: selector {} with representation {}",
2369 req.selector.canonical(),
2370 req.representation.name()
2371 )));
2372 }
2373 };
2374 Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
2375 }
2376
2377 #[cfg(feature = "docx")]
2378 fn docx_table(
2379 &mut self,
2380 req: &ObserveRequest,
2381 story: DocxStory,
2382 index: u32,
2383 profile: &DocxExtractProfile,
2384 ) -> Result<FieldAnswer> {
2385 let v = self.docx_story_view(story, profile)?;
2386 let t = v.model.tables().find(|t| t.index == index).ok_or_else(|| {
2387 Error::unsupported_feature(format!("DOCX story {} has no table {index}", story.name()))
2388 })?;
2389 let text = t.text();
2390 let rows = t.rows.len();
2391 let cells: Vec<usize> = t.rows.iter().map(|r| r.cells.len()).collect();
2392 let provenance = format!(
2393 "docx;story={};part={};table={};profile={}",
2394 story.name(),
2395 v.part.name,
2396 index,
2397 profile.fingerprint()
2398 );
2399 let value = match req.representation {
2400 Representation::Text => AnswerValue::Text(text),
2401 Representation::Metadata => {
2402 let dims = cells
2403 .iter()
2404 .map(|c| c.to_string())
2405 .collect::<Vec<_>>()
2406 .join(",");
2407 AnswerValue::Json(format!(
2408 concat!(
2409 "{{\"story\":\"{}\",\"part\":\"{}\",\"table\":{},",
2410 "\"rows\":{},\"cells_per_row\":[{}],\"profile\":\"{}\"}}"
2411 ),
2412 json_escape(&story.name()),
2413 json_escape(&v.part.name),
2414 index,
2415 rows,
2416 dims,
2417 profile.fingerprint(),
2418 ))
2419 }
2420 _ => {
2421 return Err(Error::unsupported_feature(format!(
2422 "unsupported observation: selector {} with representation {}",
2423 req.selector.canonical(),
2424 req.representation.name()
2425 )));
2426 }
2427 };
2428 Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
2429 }
2430
2431 #[cfg(feature = "docx")]
2432 fn docx_cell(
2433 &mut self,
2434 req: &ObserveRequest,
2435 story: DocxStory,
2436 table: u32,
2437 cell: &str,
2438 profile: &DocxExtractProfile,
2439 ) -> Result<FieldAnswer> {
2440 let (col, row_idx) = parse_cell_ref(cell).ok_or_else(|| {
2441 Error::usage(format!("cell reference {cell:?} is not A1-style (e.g. B7)"))
2442 })?;
2443 let v = self.docx_story_view(story, profile)?;
2444 let t = v.model.tables().find(|t| t.index == table).ok_or_else(|| {
2445 Error::unsupported_feature(format!("DOCX story {} has no table {table}", story.name()))
2446 })?;
2447 let r = t.rows.get(row_idx as usize).ok_or_else(|| {
2448 Error::unsupported_feature(format!("DOCX table {table} has no row {}", row_idx + 1))
2449 })?;
2450 let found = r
2451 .cells
2452 .iter()
2453 .find(|c| col >= c.grid_col && col < c.grid_col.saturating_add(c.grid_span))
2454 .ok_or_else(|| {
2455 Error::unsupported_feature(format!(
2456 "DOCX table {table} row {} has no cell {cell}",
2457 row_idx + 1
2458 ))
2459 })?;
2460 let text = found.text.clone();
2461 let grid_col = found.grid_col;
2462 let grid_span = found.grid_span;
2463 let vmerge = found.vmerge_continue;
2464 let provenance = format!(
2465 "docx;story={};part={};table={};row={};cell={};profile={}",
2466 story.name(),
2467 v.part.name,
2468 table,
2469 row_idx + 1,
2470 cell,
2471 profile.fingerprint()
2472 );
2473 let value = match req.representation {
2474 Representation::Text => AnswerValue::Text(text),
2475 Representation::Metadata => AnswerValue::Json(format!(
2476 concat!(
2477 "{{\"story\":\"{}\",\"part\":\"{}\",\"table\":{},",
2478 "\"row\":{},\"cell\":\"{}\",\"grid_col\":{},\"grid_span\":{},",
2479 "\"vmerge_continue\":{},\"text_len\":{},\"profile\":\"{}\"}}"
2480 ),
2481 json_escape(&story.name()),
2482 json_escape(&v.part.name),
2483 table,
2484 row_idx + 1,
2485 json_escape(cell),
2486 grid_col,
2487 grid_span,
2488 vmerge,
2489 text.len(),
2490 profile.fingerprint(),
2491 )),
2492 _ => {
2493 return Err(Error::unsupported_feature(format!(
2494 "unsupported observation: selector {} with representation {}",
2495 req.selector.canonical(),
2496 req.representation.name()
2497 )));
2498 }
2499 };
2500 Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
2501 }
2502
2503 #[cfg(feature = "docx")]
2504 fn docx_find(
2505 &mut self,
2506 req: &ObserveRequest,
2507 story: DocxStory,
2508 pattern: &str,
2509 profile: &DocxExtractProfile,
2510 ) -> Result<FieldAnswer> {
2511 let v = self.docx_story_view(story, profile)?;
2512 let mut items: Vec<String> = Vec::new();
2513 let mut estimated: u64 = 0;
2514 for p in v.model.paragraphs() {
2515 if p.text.contains(pattern) {
2516 estimated = estimated.saturating_add(p.text.len() as u64 + 48);
2517 if estimated > req.budget.max_output_bytes {
2518 return Err(Error::resource_limit(format!(
2519 "DOCX find exceeded the {}-byte budget",
2520 req.budget.max_output_bytes
2521 )));
2522 }
2523 items.push(format!(
2524 "{{\"paragraph\":{},\"text\":\"{}\"}}",
2525 p.index,
2526 json_escape(&p.text)
2527 ));
2528 }
2529 }
2530 let provenance = format!(
2531 "docx;story={};part={};profile={}",
2532 story.name(),
2533 v.part.name,
2534 profile.fingerprint()
2535 );
2536 Ok(self.docx_answer(
2537 req,
2538 AnswerValue::Json(format!("[{}]", items.join(","))),
2539 provenance,
2540 v.span,
2541 v.deps,
2542 ))
2543 }
2544
2545 fn decoded_node(&mut self, object: u32, encoded_id: &NodeId) -> Result<SeedNode> {
2554 let entries = self.lookup(SelectorKey::new(SEL_STREAM_DECODED, object))?;
2555 if let Some(entry) = entries.into_iter().next() {
2556 return self.load(&entry.node_id);
2557 }
2558 self.deepen_stream(object, encoded_id)
2559 }
2560
2561 fn deepen_stream(&mut self, object: u32, encoded_id: &NodeId) -> Result<SeedNode> {
2564 let encoded_node = self.load(encoded_id)?;
2565 let encoded = self.materialize(&encoded_node)?;
2566 let cap = usize::try_from(self.limits.max_output_bytes).unwrap_or(usize::MAX);
2567 let decoded = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&encoded, cap)
2568 .map_err(|e| {
2569 Error::unsupported_feature(format!(
2570 "stream {object} has no recovered decoded representation: {:?}",
2571 e.status
2572 ))
2573 })?;
2574 let node = SeedNode::new(
2575 NodeKind::PdfStreamDecoded,
2576 decoded.len() as u64,
2577 u32_params(object),
2578 vec![*encoded_id],
2579 "pdf:stream-decoded",
2580 );
2581 self.store.seeds_mut().put_node(&node.encode_canonical())?;
2582 self.stats.deepened = true;
2583 Ok(node)
2584 }
2585}
2586
2587#[cfg(feature = "epub")]
2588fn epub_opt_str(v: Option<&str>) -> String {
2589 match v {
2590 Some(s) => format!("\"{}\"", json_escape(s)),
2591 None => "null".to_string(),
2592 }
2593}
2594
2595#[cfg(feature = "epub")]
2596fn epub_opt_u32(v: Option<u32>) -> String {
2597 match v {
2598 Some(n) => n.to_string(),
2599 None => "null".to_string(),
2600 }
2601}
2602
2603#[cfg(feature = "epub")]
2604fn epub_str_array(items: &[String]) -> String {
2605 format!(
2606 "[{}]",
2607 items
2608 .iter()
2609 .map(|s| format!("\"{}\"", json_escape(s)))
2610 .collect::<Vec<_>>()
2611 .join(",")
2612 )
2613}
2614
2615#[cfg(feature = "epub")]
2616fn epub_dir_of(name: &str) -> String {
2617 match name.rfind('/') {
2618 Some(i) => name[..=i].to_string(),
2619 None => String::new(),
2620 }
2621}
2622
2623#[cfg(feature = "epub")]
2624fn epub_mimetype_json(m: &crate::adapter::epub::MimetypeFacts) -> String {
2625 format!(
2626 "{{\"present\":{},\"first\":{},\"stored\":{},\"no_extra\":{},\"exact_bytes\":{},\"conformant\":{}}}",
2627 m.present, m.first, m.stored, m.no_extra, m.exact_bytes, m.conformant
2628 )
2629}
2630
2631#[cfg(feature = "epub")]
2632fn epub_rootfile_json(r: &crate::adapter::epub::RootFile) -> String {
2633 let ord = if r.ordinal == u32::MAX {
2634 None
2635 } else {
2636 Some(r.ordinal)
2637 };
2638 format!(
2639 "{{\"full_path\":\"{}\",\"member\":\"{}\",\"media_type\":\"{}\",\"ordinal\":{}}}",
2640 json_escape(&r.full_path),
2641 json_escape(&r.member),
2642 json_escape(&r.media_type),
2643 epub_opt_u32(ord)
2644 )
2645}
2646
2647#[cfg(feature = "epub")]
2648fn epub_meta_json(e: &crate::adapter::epub::MetadataEntry) -> String {
2649 format!(
2650 "{{\"name\":\"{}\",\"property\":{},\"refines\":{},\"id\":{},\"scheme\":{},\"value\":\"{}\"}}",
2651 json_escape(&e.name),
2652 epub_opt_str(e.property.as_deref()),
2653 epub_opt_str(e.refines.as_deref()),
2654 epub_opt_str(e.id.as_deref()),
2655 epub_opt_str(e.scheme.as_deref()),
2656 json_escape(&e.value)
2657 )
2658}
2659
2660#[cfg(feature = "epub")]
2661fn epub_manifest_json(it: &crate::adapter::epub::ManifestItem) -> String {
2662 format!(
2663 "{{\"id\":\"{}\",\"href\":\"{}\",\"media_type\":\"{}\",\"properties\":{},\"fallback\":{},\"resolved\":{},\"ordinal\":{},\"external\":{}}}",
2664 json_escape(&it.id),
2665 json_escape(&it.href),
2666 json_escape(&it.media_type),
2667 epub_str_array(&it.properties),
2668 epub_opt_str(it.fallback.as_deref()),
2669 epub_opt_str(it.resolved.as_deref()),
2670 epub_opt_u32(it.resolved_ordinal()),
2671 it.external
2672 )
2673}
2674
2675#[cfg(feature = "epub")]
2676fn epub_spine_json(s: &crate::adapter::epub::SpineItemRef) -> String {
2677 let index = if s.item_index == u32::MAX {
2678 None
2679 } else {
2680 Some(s.item_index)
2681 };
2682 let ord = if s.ordinal == u32::MAX {
2683 None
2684 } else {
2685 Some(s.ordinal)
2686 };
2687 format!(
2688 "{{\"idref\":\"{}\",\"linear\":{},\"properties\":{},\"item_index\":{},\"ordinal\":{}}}",
2689 json_escape(&s.idref),
2690 s.linear,
2691 epub_str_array(&s.properties),
2692 epub_opt_u32(index),
2693 epub_opt_u32(ord)
2694 )
2695}
2696
2697#[cfg(feature = "epub")]
2698fn epub_nav_json(index: u32, e: &crate::adapter::epub::NavEntry) -> String {
2699 format!(
2700 "{{\"index\":{},\"depth\":{},\"nav\":\"{}\",\"label\":\"{}\",\"href\":\"{}\",\"member\":{},\"fragment\":{},\"external\":{}}}",
2701 index,
2702 e.depth,
2703 json_escape(&e.nav_type),
2704 json_escape(&e.label),
2705 json_escape(&e.href),
2706 epub_opt_str(e.member.as_deref()),
2707 epub_opt_str(e.fragment.as_deref()),
2708 e.external
2709 )
2710}
2711
2712#[cfg(feature = "epub")]
2713fn epub_block_json(index: u32, b: &crate::adapter::epub::Block) -> String {
2714 use crate::adapter::epub::Block;
2715 match b {
2716 Block::Heading {
2717 level,
2718 id,
2719 epub_type,
2720 text,
2721 } => format!(
2722 "{{\"index\":{index},\"kind\":\"heading\",\"level\":{level},\"id\":{},\"epub_type\":{},\"text\":\"{}\"}}",
2723 epub_opt_str(id.as_deref()),
2724 epub_opt_str(epub_type.as_deref()),
2725 json_escape(text)
2726 ),
2727 Block::Paragraph { text } => format!(
2728 "{{\"index\":{index},\"kind\":\"paragraph\",\"text\":\"{}\"}}",
2729 json_escape(text)
2730 ),
2731 Block::List { ordered, items } => format!(
2732 "{{\"index\":{index},\"kind\":\"list\",\"ordered\":{ordered},\"items\":{}}}",
2733 epub_str_array(items)
2734 ),
2735 Block::Table { rows } => format!(
2736 "{{\"index\":{index},\"kind\":\"table\",\"rows\":{},\"cols\":{}}}",
2737 rows.len(),
2738 rows.first().map_or(0, |r| r.cells.len())
2739 ),
2740 }
2741}
2742
2743#[cfg(feature = "epub")]
2744fn epub_cell_json(table: u32, row: u32, col: u32, c: &crate::adapter::epub::Cell) -> String {
2745 format!(
2746 "{{\"table\":{table},\"row\":{row},\"col\":{col},\"header\":{},\"colspan\":{},\"rowspan\":{},\"text_len\":{}}}",
2747 c.header,
2748 c.colspan,
2749 c.rowspan,
2750 c.text.len()
2751 )
2752}
2753
2754#[cfg(feature = "epub")]
2755fn epub_link_json(index: u32, l: &crate::adapter::epub::Link) -> String {
2756 format!(
2757 "{{\"index\":{index},\"href\":\"{}\",\"text\":\"{}\",\"fragment\":{},\"member\":{},\"external\":{},\"epub_type\":{}}}",
2758 json_escape(&l.href),
2759 json_escape(&l.text),
2760 epub_opt_str(l.fragment.as_deref()),
2761 epub_opt_str(l.member.as_deref()),
2762 l.external,
2763 epub_opt_str(l.epub_type.as_deref())
2764 )
2765}
2766
2767#[cfg(feature = "epub")]
2768fn epub_resource_json(index: usize, r: &crate::adapter::epub::Resource) -> String {
2769 format!(
2770 "{{\"index\":{index},\"kind\":\"{}\",\"attr\":\"{}\",\"value\":\"{}\",\"member\":{},\"external\":{}}}",
2771 json_escape(&r.kind),
2772 json_escape(&r.attr),
2773 json_escape(&r.value),
2774 epub_opt_str(r.member.as_deref()),
2775 r.external
2776 )
2777}
2778
2779#[cfg(feature = "epub")]
2780fn epub_section_json(index: usize, s: &crate::adapter::epub::Section) -> String {
2781 format!(
2782 "{{\"index\":{index},\"local\":\"{}\",\"epub_type\":{},\"depth\":{}}}",
2783 json_escape(&s.local),
2784 epub_opt_str(s.epub_type.as_deref()),
2785 s.depth
2786 )
2787}
2788
2789#[cfg(feature = "epub")]
2790fn epub_content_structure_json(
2791 index: u32,
2792 model: &crate::adapter::epub::ContentModel,
2793 item: &ManifestItem,
2794 profile: &EpubExtractProfile,
2795) -> String {
2796 let blocks = model
2797 .blocks
2798 .iter()
2799 .enumerate()
2800 .map(|(i, b)| epub_block_json(i as u32, b))
2801 .collect::<Vec<_>>()
2802 .join(",");
2803 let headings = model
2804 .blocks
2805 .iter()
2806 .enumerate()
2807 .filter(|(_, b)| matches!(b, crate::adapter::epub::Block::Heading { .. }))
2808 .map(|(i, b)| epub_block_json(i as u32, b))
2809 .collect::<Vec<_>>()
2810 .join(",");
2811 let links = model
2812 .links
2813 .iter()
2814 .enumerate()
2815 .map(|(i, l)| epub_link_json(i as u32, l))
2816 .collect::<Vec<_>>()
2817 .join(",");
2818 let resources = model
2819 .resources
2820 .iter()
2821 .enumerate()
2822 .map(|(i, r)| epub_resource_json(i, r))
2823 .collect::<Vec<_>>()
2824 .join(",");
2825 let sections = model
2826 .sections
2827 .iter()
2828 .enumerate()
2829 .map(|(i, s)| epub_section_json(i, s))
2830 .collect::<Vec<_>>()
2831 .join(",");
2832 format!(
2833 concat!(
2834 "{{\"spine\":{},\"part\":\"{}\",\"profile\":\"{}\",\"root\":\"{}\",",
2835 "\"body\":{},\"scripted\":{},\"xhtml_nodes\":{},",
2836 "\"blocks\":[{}],\"headings\":[{}],\"links\":[{}],",
2837 "\"resources\":[{}],\"fragments\":{},\"sections\":[{}]}}"
2838 ),
2839 index,
2840 json_escape(item.resolved.as_deref().unwrap_or("")),
2841 profile.fingerprint(),
2842 json_escape(&model.root_local),
2843 model.body_seen,
2844 model.scripted,
2845 model.xhtml_nodes,
2846 blocks,
2847 headings,
2848 links,
2849 resources,
2850 epub_str_array(&model.fragments),
2851 sections
2852 )
2853}
2854
2855#[cfg(feature = "epub")]
2856type EpubContentView = (
2857 crate::adapter::epub::ContentModel,
2858 ManifestItem,
2859 Option<(u64, u64)>,
2860 Vec<NodeId>,
2861);
2862
2863#[cfg(feature = "epub")]
2864impl<S: SeedStore> Ctx<'_, S> {
2865 fn epub_model(&mut self) -> Result<EpubModel> {
2866 let entry = self.require_entry(SelectorKey::new(SEL_EPUB_MODEL, 0), "EPUB model")?;
2867 let node = self.load(&entry.node_id)?;
2868 let bytes = self.materialize(&node)?;
2869 EpubModel::decode(&bytes)
2870 }
2871
2872 fn epub_package_doc(&mut self) -> Result<PackageDoc> {
2873 let model = self.epub_model()?;
2874 model.package.ok_or_else(|| {
2875 Error::invalid_package_structure("EPUB container has no resolvable package document")
2876 })
2877 }
2878
2879 fn epub_member_decoded_bytes(&mut self, ordinal: u32) -> Result<Vec<u8>> {
2880 let entry = self.require_entry(
2881 SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, ordinal),
2882 "EPUB resource decoded bytes",
2883 )?;
2884 let node = self.load(&entry.node_id)?;
2885 self.stats.member_decodes = self.stats.member_decodes.saturating_add(1);
2886 self.materialize(&node)
2887 }
2888
2889 fn epub_member_span(&mut self, ordinal: Option<u32>) -> Option<(u64, u64)> {
2890 let o = ordinal?;
2891 self.lookup(SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, o))
2892 .ok()?
2893 .into_iter()
2894 .next()
2895 .map(|e| (e.out_off, e.out_off.saturating_add(e.out_len)))
2896 }
2897
2898 fn epub_answer(
2899 &self,
2900 req: &ObserveRequest,
2901 value: AnswerValue,
2902 provenance: String,
2903 span: Option<(u64, u64)>,
2904 deps: Vec<NodeId>,
2905 ) -> FieldAnswer {
2906 FieldAnswer {
2907 value,
2908 basis: Basis::DeterministicallyDerived,
2909 selector: req.selector.canonical(),
2910 representation: req.representation.name().to_string(),
2911 source_span: span,
2912 provenance,
2913 dependency_ids: deps,
2914 integrity_scope: IntegrityScope::None,
2915 exact: false,
2916 }
2917 }
2918
2919 fn epub_package(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
2920 let model = self.epub_model()?;
2921 let doc = model.package.as_ref().ok_or_else(|| {
2922 Error::invalid_package_structure("EPUB container has no resolvable package document")
2923 })?;
2924 let rootfiles = model
2925 .rootfiles
2926 .iter()
2927 .map(epub_rootfile_json)
2928 .collect::<Vec<_>>()
2929 .join(",");
2930 let mut s = String::new();
2931 s.push_str("{\"package\":\"");
2932 s.push_str(&json_escape(&doc.member));
2933 s.push_str("\",\"version\":");
2934 s.push_str(&epub_opt_str(doc.version.as_deref()));
2935 s.push_str(",\"unique_identifier\":");
2936 s.push_str(&epub_opt_str(doc.unique_identifier.as_deref()));
2937 s.push_str(",\"page_progression_direction\":");
2938 s.push_str(&epub_opt_str(doc.page_progression.as_deref()));
2939 s.push_str(",\"rendition_layout\":");
2940 s.push_str(&epub_opt_str(doc.layout.as_deref()));
2941 s.push_str(",\"cover_id\":");
2942 s.push_str(&epub_opt_str(doc.cover_id.as_deref()));
2943 s.push_str(",\"nav_item\":");
2944 s.push_str(&epub_opt_u32(doc.nav_item));
2945 s.push_str(",\"ncx_item\":");
2946 s.push_str(&epub_opt_u32(doc.ncx_item));
2947 s.push_str(&format!(
2948 ",\"manifest_items\":{},\"spine_items\":{},\"metadata_entries\":{}",
2949 doc.manifest.len(),
2950 doc.spine.len(),
2951 doc.metadata.len()
2952 ));
2953 s.push_str(",\"rootfiles\":[");
2954 s.push_str(&rootfiles);
2955 s.push_str("],\"mimetype\":");
2956 s.push_str(&epub_mimetype_json(&model.mimetype));
2957 if req.representation == Representation::Structure {
2958 let man = doc
2959 .manifest
2960 .iter()
2961 .map(epub_manifest_json)
2962 .collect::<Vec<_>>()
2963 .join(",");
2964 let sp = doc
2965 .spine
2966 .iter()
2967 .map(epub_spine_json)
2968 .collect::<Vec<_>>()
2969 .join(",");
2970 let md = doc
2971 .metadata
2972 .iter()
2973 .map(epub_meta_json)
2974 .collect::<Vec<_>>()
2975 .join(",");
2976 s.push_str(",\"manifest\":[");
2977 s.push_str(&man);
2978 s.push_str("],\"spine\":[");
2979 s.push_str(&sp);
2980 s.push_str("],\"metadata\":[");
2981 s.push_str(&md);
2982 s.push(']');
2983 }
2984 s.push_str(",\"issues\":");
2985 s.push_str(&epub_str_array(&doc.issues));
2986 s.push('}');
2987 let provenance = format!("epub;package={}", doc.member);
2988 Ok(self.epub_answer(req, AnswerValue::Json(s), provenance, None, Vec::new()))
2989 }
2990
2991 fn epub_manifest_item_meta(&mut self, req: &ObserveRequest, index: u32) -> Result<FieldAnswer> {
2992 let doc = self.epub_package_doc()?;
2993 let item = doc
2994 .manifest
2995 .get(index as usize)
2996 .ok_or_else(|| Error::unsupported_feature(format!("no EPUB manifest item {index}")))?;
2997 let json = epub_manifest_json(item);
2998 let provenance = format!("epub;manifest={index};id={}", item.id);
2999 let span = self.epub_member_span(item.resolved_ordinal());
3000 Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, Vec::new()))
3001 }
3002
3003 fn epub_manifest_item_bytes(
3004 &mut self,
3005 req: &ObserveRequest,
3006 index: u32,
3007 ) -> Result<FieldAnswer> {
3008 let doc = self.epub_package_doc()?;
3009 let item =
3010 doc.manifest.get(index as usize).cloned().ok_or_else(|| {
3011 Error::unsupported_feature(format!("no EPUB manifest item {index}"))
3012 })?;
3013 self.epub_item_bytes(req, &item)
3014 }
3015
3016 fn epub_item_bytes(
3017 &mut self,
3018 req: &ObserveRequest,
3019 item: &ManifestItem,
3020 ) -> Result<FieldAnswer> {
3021 if item.external {
3022 return Err(Error::invalid_package_structure(format!(
3023 "EPUB manifest item {:?} is an external target: inert, never fetched",
3024 item.id
3025 )));
3026 }
3027 let ordinal = item.resolved_ordinal().ok_or_else(|| {
3028 Error::invalid_package_structure(format!(
3029 "EPUB manifest item {:?} has no resolvable container member",
3030 item.id
3031 ))
3032 })?;
3033 if req.representation == Representation::ExactBytes {
3034 self.indexed_exact(
3035 req,
3036 SelectorKey::new(SEL_PACKAGE_MEMBER_RAW, ordinal),
3037 "EPUB resource",
3038 )
3039 } else {
3040 self.member_decoded(req, ordinal)
3041 }
3042 }
3043
3044 fn epub_spine_item_meta(
3045 &mut self,
3046 req: &ObserveRequest,
3047 index: u32,
3048 profile: &EpubExtractProfile,
3049 ) -> Result<FieldAnswer> {
3050 let doc = self.epub_package_doc()?;
3051 let order = doc.reading_order(profile);
3052 let mi = *order.get(index as usize).ok_or_else(|| {
3053 Error::unsupported_feature(format!(
3054 "no EPUB spine item {index} under profile {}",
3055 profile.fingerprint()
3056 ))
3057 })?;
3058 if mi == u32::MAX {
3059 return Err(Error::invalid_package_structure(
3060 "EPUB spine item does not resolve to a manifest item",
3061 ));
3062 }
3063 let item = doc.manifest.get(mi as usize).ok_or_else(|| {
3064 Error::invalid_package_structure("EPUB spine item manifest index is out of range")
3065 })?;
3066 let spine_ref = doc.spine.iter().find(|s| s.item_index == mi);
3067 let json = match spine_ref {
3068 Some(s) => format!(
3069 "{{\"index\":{},\"profile\":\"{}\",\"idref\":\"{}\",\"linear\":{},\"properties\":{},\"item\":{}}}",
3070 index,
3071 profile.fingerprint(),
3072 json_escape(&s.idref),
3073 s.linear,
3074 epub_str_array(&s.properties),
3075 epub_manifest_json(item)
3076 ),
3077 None => format!(
3078 "{{\"index\":{},\"profile\":\"{}\",\"idref\":null,\"item\":{}}}",
3079 index,
3080 profile.fingerprint(),
3081 epub_manifest_json(item)
3082 ),
3083 };
3084 let provenance = format!(
3085 "epub;spine={index};profile={};id={};part={}",
3086 profile.fingerprint(),
3087 item.id,
3088 item.resolved.as_deref().unwrap_or("")
3089 );
3090 let span = self.epub_member_span(item.resolved_ordinal());
3091 Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, Vec::new()))
3092 }
3093
3094 fn epub_spine_item_bytes(
3095 &mut self,
3096 req: &ObserveRequest,
3097 index: u32,
3098 profile: &EpubExtractProfile,
3099 ) -> Result<FieldAnswer> {
3100 let doc = self.epub_package_doc()?;
3101 let order = doc.reading_order(profile);
3102 let mi = *order.get(index as usize).ok_or_else(|| {
3103 Error::unsupported_feature(format!(
3104 "no EPUB spine item {index} under profile {}",
3105 profile.fingerprint()
3106 ))
3107 })?;
3108 let item = doc.manifest.get(mi as usize).cloned().ok_or_else(|| {
3109 Error::invalid_package_structure("EPUB spine item manifest index is out of range")
3110 })?;
3111 self.epub_item_bytes(req, &item)
3112 }
3113
3114 fn epub_content_view(
3119 &mut self,
3120 index: u32,
3121 profile: &EpubExtractProfile,
3122 ) -> Result<EpubContentView> {
3123 let doc = self.epub_package_doc()?;
3124 let order = doc.reading_order(profile);
3125 let mi = *order.get(index as usize).ok_or_else(|| {
3126 Error::unsupported_feature(format!(
3127 "no EPUB spine item {index} under profile {}",
3128 profile.fingerprint()
3129 ))
3130 })?;
3131 let item = doc.manifest.get(mi as usize).cloned().ok_or_else(|| {
3132 Error::invalid_package_structure("EPUB spine item manifest index is out of range")
3133 })?;
3134 let ordinal = item.resolved_ordinal().ok_or_else(|| {
3135 Error::invalid_package_structure(format!(
3136 "EPUB spine item {:?} has no resolvable container member",
3137 item.id
3138 ))
3139 })?;
3140 let dec = self.require_entry(
3141 SelectorKey::new(SEL_PACKAGE_MEMBER_DECODED, ordinal),
3142 "EPUB spine content decoded bytes",
3143 )?;
3144 self.stats.member_decodes = self.stats.member_decodes.saturating_add(1);
3145 let base_dir = epub_dir_of(item.resolved.as_deref().unwrap_or(""));
3146 let mut node = SeedNode::new(
3147 NodeKind::EpubContent,
3148 self.limits.max_output_bytes,
3149 crate::adapter::epub::content_params(index, ordinal, &base_dir, profile),
3150 vec![dec.node_id],
3151 "epub:content",
3152 );
3153 node.limits.max_output_bytes = self.limits.max_output_bytes;
3154 let id = node.content_id();
3155 let bytes = self.materialize(&node)?;
3156 let model = crate::adapter::epub::ContentModel::decode(&bytes)?;
3157 let span = self.epub_member_span(Some(ordinal));
3158 Ok((model, item, span, vec![id, dec.node_id]))
3159 }
3160
3161 fn epub_spine_item_text(
3162 &mut self,
3163 req: &ObserveRequest,
3164 index: u32,
3165 profile: &EpubExtractProfile,
3166 ) -> Result<FieldAnswer> {
3167 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3168 let provenance = format!(
3169 "epub;spine={index};part={};profile={};content",
3170 item.resolved.as_deref().unwrap_or(""),
3171 profile.fingerprint()
3172 );
3173 Ok(self.epub_answer(req, AnswerValue::Text(model.text()), provenance, span, deps))
3174 }
3175
3176 fn epub_spine_item_structure(
3177 &mut self,
3178 req: &ObserveRequest,
3179 index: u32,
3180 profile: &EpubExtractProfile,
3181 ) -> Result<FieldAnswer> {
3182 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3183 let json = epub_content_structure_json(index, &model, &item, profile);
3184 let provenance = format!(
3185 "epub;spine={index};part={};profile={};structure",
3186 item.resolved.as_deref().unwrap_or(""),
3187 profile.fingerprint()
3188 );
3189 Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, deps))
3190 }
3191
3192 fn epub_spine_item_preview(
3193 &mut self,
3194 req: &ObserveRequest,
3195 index: u32,
3196 profile: &EpubExtractProfile,
3197 ) -> Result<FieldAnswer> {
3198 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3199 let text = model.preview_text(
3200 index,
3201 item.resolved.as_deref().unwrap_or(""),
3202 &profile.fingerprint(),
3203 );
3204 let provenance = format!(
3205 "epub;spine={index};part={};profile={};preview",
3206 item.resolved.as_deref().unwrap_or(""),
3207 profile.fingerprint()
3208 );
3209 Ok(self.epub_answer(req, AnswerValue::Text(text), provenance, span, deps))
3210 }
3211
3212 fn epub_block(
3213 &mut self,
3214 req: &ObserveRequest,
3215 index: u32,
3216 block: u32,
3217 profile: &EpubExtractProfile,
3218 ) -> Result<FieldAnswer> {
3219 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3220 let b = model.blocks.get(block as usize).ok_or_else(|| {
3221 Error::unsupported_feature(format!("EPUB spine item {index} has no block {block}"))
3222 })?;
3223 let provenance = format!(
3224 "epub;spine={index};part={};block={block};profile={}",
3225 item.resolved.as_deref().unwrap_or(""),
3226 profile.fingerprint()
3227 );
3228 let value = match req.representation {
3229 Representation::Text => AnswerValue::Text(b.text()),
3230 Representation::Metadata | Representation::Structure => {
3231 AnswerValue::Json(epub_block_json(block, b))
3232 }
3233 _ => {
3234 return Err(Error::unsupported_feature(format!(
3235 "unsupported observation: selector {} with representation {}",
3236 req.selector.canonical(),
3237 req.representation.name()
3238 )));
3239 }
3240 };
3241 Ok(self.epub_answer(req, value, provenance, span, deps))
3242 }
3243
3244 fn epub_cell(
3245 &mut self,
3246 req: &ObserveRequest,
3247 index: u32,
3248 table: u32,
3249 row: u32,
3250 col: u32,
3251 profile: &EpubExtractProfile,
3252 ) -> Result<FieldAnswer> {
3253 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3254 let t = model.table(table).ok_or_else(|| {
3255 Error::unsupported_feature(format!("EPUB spine item {index} has no table {table}"))
3256 })?;
3257 let crate::adapter::epub::Block::Table { rows } = t else {
3258 return Err(Error::internal_invariant(
3259 "table selector resolved a non-table",
3260 ));
3261 };
3262 let r = rows.get(row as usize).ok_or_else(|| {
3263 Error::unsupported_feature(format!("EPUB table {table} has no row {row}"))
3264 })?;
3265 let c = r.cells.get(col as usize).ok_or_else(|| {
3266 Error::unsupported_feature(format!("EPUB table {table} row {row} has no cell {col}"))
3267 })?;
3268 let provenance = format!(
3269 "epub;spine={index};part={};table={table};row={row};col={col};profile={}",
3270 item.resolved.as_deref().unwrap_or(""),
3271 profile.fingerprint()
3272 );
3273 let value = match req.representation {
3274 Representation::Text => AnswerValue::Text(c.text.clone()),
3275 Representation::Metadata => AnswerValue::Json(epub_cell_json(table, row, col, c)),
3276 _ => {
3277 return Err(Error::unsupported_feature(format!(
3278 "unsupported observation: selector {} with representation {}",
3279 req.selector.canonical(),
3280 req.representation.name()
3281 )));
3282 }
3283 };
3284 Ok(self.epub_answer(req, value, provenance, span, deps))
3285 }
3286
3287 fn epub_link(
3288 &mut self,
3289 req: &ObserveRequest,
3290 index: u32,
3291 link: u32,
3292 profile: &EpubExtractProfile,
3293 ) -> Result<FieldAnswer> {
3294 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3295 let l = model.links.get(link as usize).ok_or_else(|| {
3296 Error::unsupported_feature(format!("EPUB spine item {index} has no link {link}"))
3297 })?;
3298 let provenance = format!(
3299 "epub;spine={index};part={};link={link};profile={}",
3300 item.resolved.as_deref().unwrap_or(""),
3301 profile.fingerprint()
3302 );
3303 Ok(self.epub_answer(
3304 req,
3305 AnswerValue::Json(epub_link_json(link, l)),
3306 provenance,
3307 span,
3308 deps,
3309 ))
3310 }
3311
3312 fn epub_find(
3313 &mut self,
3314 req: &ObserveRequest,
3315 index: u32,
3316 pattern: &str,
3317 profile: &EpubExtractProfile,
3318 ) -> Result<FieldAnswer> {
3319 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3320 let mut items: Vec<String> = Vec::new();
3321 let mut estimated: u64 = 0;
3322 for (i, b) in model.blocks.iter().enumerate() {
3323 let t = b.text();
3324 if t.contains(pattern) {
3325 estimated = estimated.saturating_add(t.len() as u64 + 48);
3326 if estimated > req.budget.max_output_bytes {
3327 return Err(Error::resource_limit(format!(
3328 "EPUB find exceeded the {}-byte budget",
3329 req.budget.max_output_bytes
3330 )));
3331 }
3332 items.push(format!(
3333 "{{\"block\":{i},\"kind\":\"{}\",\"text\":\"{}\"}}",
3334 b.kind(),
3335 json_escape(&t)
3336 ));
3337 }
3338 }
3339 let provenance = format!(
3340 "epub;spine={index};part={};profile={};find",
3341 item.resolved.as_deref().unwrap_or(""),
3342 profile.fingerprint()
3343 );
3344 Ok(self.epub_answer(
3345 req,
3346 AnswerValue::Json(format!("[{}]", items.join(","))),
3347 provenance,
3348 span,
3349 deps,
3350 ))
3351 }
3352
3353 fn epub_resource_meta(&mut self, req: &ObserveRequest, name: &str) -> Result<FieldAnswer> {
3354 let doc = self.epub_package_doc()?;
3355 let item = doc
3356 .manifest
3357 .iter()
3358 .find(|m| m.resolved.as_deref() == Some(name))
3359 .ok_or_else(|| {
3360 Error::invalid_package_structure(format!("no EPUB resource named {name:?}"))
3361 })?;
3362 let json = epub_manifest_json(item);
3363 let span = self.epub_member_span(item.resolved_ordinal());
3364 Ok(self.epub_answer(
3365 req,
3366 AnswerValue::Json(json),
3367 format!("epub;resource={name}"),
3368 span,
3369 Vec::new(),
3370 ))
3371 }
3372
3373 fn epub_resource_bytes(&mut self, req: &ObserveRequest, name: &str) -> Result<FieldAnswer> {
3374 let doc = self.epub_package_doc()?;
3375 let item = doc
3376 .manifest
3377 .iter()
3378 .find(|m| m.resolved.as_deref() == Some(name))
3379 .cloned()
3380 .ok_or_else(|| {
3381 Error::invalid_package_structure(format!("no EPUB resource named {name:?}"))
3382 })?;
3383 self.epub_item_bytes(req, &item)
3384 }
3385
3386 fn epub_nav(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3387 let doc = self.epub_package_doc()?;
3388 let idx = doc.nav_item.ok_or_else(|| {
3389 Error::invalid_package_structure(
3390 "EPUB package has no navigation document (properties nav)",
3391 )
3392 })?;
3393 let item = doc.manifest.get(idx as usize).cloned().ok_or_else(|| {
3394 Error::invalid_package_structure("EPUB nav manifest index is out of range")
3395 })?;
3396 let ordinal = item.resolved_ordinal().ok_or_else(|| {
3397 Error::invalid_package_structure("EPUB navigation document has no resolvable member")
3398 })?;
3399 let bytes = self.epub_member_decoded_bytes(ordinal)?;
3400 let base = epub_dir_of(item.resolved.as_deref().unwrap_or(""));
3401 let entries = crate::adapter::epub::parse_nav_document(&bytes, &base, self.limits)?;
3402 let body = entries
3403 .iter()
3404 .enumerate()
3405 .map(|(i, e)| epub_nav_json(i as u32, e))
3406 .collect::<Vec<_>>()
3407 .join(",");
3408 let json = format!(
3409 "{{\"nav_item\":\"{}\",\"entries\":[{}]}}",
3410 json_escape(&item.id),
3411 body
3412 );
3413 let provenance = format!(
3414 "epub;nav={};part={}",
3415 item.id,
3416 item.resolved.as_deref().unwrap_or("")
3417 );
3418 let span = self.epub_member_span(Some(ordinal));
3419 Ok(self.epub_answer(req, AnswerValue::Json(json), provenance, span, Vec::new()))
3420 }
3421
3422 fn epub_nav_node(&mut self, req: &ObserveRequest, index: u32) -> Result<FieldAnswer> {
3423 let doc = self.epub_package_doc()?;
3424 let idx = doc.nav_item.ok_or_else(|| {
3425 Error::invalid_package_structure(
3426 "EPUB package has no navigation document (properties nav)",
3427 )
3428 })?;
3429 let item = doc.manifest.get(idx as usize).cloned().ok_or_else(|| {
3430 Error::invalid_package_structure("EPUB nav manifest index is out of range")
3431 })?;
3432 let ordinal = item.resolved_ordinal().ok_or_else(|| {
3433 Error::invalid_package_structure("EPUB navigation document has no resolvable member")
3434 })?;
3435 let bytes = self.epub_member_decoded_bytes(ordinal)?;
3436 let base = epub_dir_of(item.resolved.as_deref().unwrap_or(""));
3437 let entries = crate::adapter::epub::parse_nav_document(&bytes, &base, self.limits)?;
3438 let e = entries
3439 .get(index as usize)
3440 .ok_or_else(|| Error::unsupported_feature(format!("no EPUB nav entry {index}")))?;
3441 let json = epub_nav_json(index, e);
3442 Ok(self.epub_answer(
3443 req,
3444 AnswerValue::Json(json),
3445 format!("epub;nav-node={index}"),
3446 None,
3447 Vec::new(),
3448 ))
3449 }
3450}
3451
3452impl<S: SeedStore> Ctx<'_, S> {
3457 fn document_format(&self) -> Option<DocumentFormat> {
3459 DocumentFormat::from_provenance(&self.manifest.provenance)
3460 }
3461
3462 fn tag_common(&self, fmt: DocumentFormat, mut answer: FieldAnswer) -> FieldAnswer {
3464 answer.provenance = format!("format={};common;{}", fmt.name(), answer.provenance);
3465 answer
3466 }
3467
3468 fn common_dispatch(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3470 use crate::field::capabilities;
3471 let fmt = self.document_format().ok_or_else(|| {
3472 Error::unsupported_feature(
3473 "field manifest does not record a document format; common observations are unavailable",
3474 )
3475 })?;
3476 if !capabilities::common_supported(fmt, &req.selector, req.representation) {
3477 return Err(Error::unsupported_feature(format!(
3478 "unsupported common observation: format {} does not support selector {} with representation {}",
3479 fmt.name(),
3480 req.selector.canonical(),
3481 req.representation.name()
3482 )));
3483 }
3484 let answer = match fmt {
3485 DocumentFormat::Pdf => self.common_pdf(req)?,
3486 DocumentFormat::Docx => self.common_docx(req)?,
3487 DocumentFormat::Epub => self.common_epub(req)?,
3488 DocumentFormat::Opaque => {
3489 return Err(Error::unsupported_feature(
3490 "opaque fields have no common observations",
3491 ));
3492 }
3493 };
3494 Ok(self.tag_common(fmt, answer))
3495 }
3496
3497 fn common_pdf(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3500 match &req.selector {
3501 Selector::Metadata => {
3502 let mut a = self.document_metadata(req)?;
3503 a.provenance = "pdf;document-metadata".to_string();
3504 Ok(a)
3505 }
3506 Selector::Text => self.pdf_document_text(req),
3507 Selector::SearchMatch(p) => self.text_match(req, p),
3508 other => Err(Error::unsupported_feature(format!(
3509 "PDF does not support common selector {}",
3510 other.canonical()
3511 ))),
3512 }
3513 }
3514
3515 fn pdf_document_text(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3518 let mut out = String::new();
3519 let mut pages: u64 = 0;
3520 let mut page: u32 = 1;
3521 while page <= MAX_TEXTMATCH_PAGES {
3522 let entries = self.lookup(SelectorKey::new(SEL_PAGE, page))?;
3523 let Some(entry) = entries.into_iter().next() else {
3524 break;
3525 };
3526 let pc = entry.node_id;
3527 let (_ops, text, _preview) = self.ensure_page_derived(page, pc)?;
3528 let bytes = self.materialize(&text)?;
3529 out.push_str(&String::from_utf8_lossy(&bytes));
3530 if !out.ends_with('\n') {
3531 out.push('\n');
3532 }
3533 if out.len() as u64 > req.budget.max_output_bytes {
3534 return Err(Error::resource_limit(format!(
3535 "whole-document text exceeded the {}-byte budget",
3536 req.budget.max_output_bytes
3537 )));
3538 }
3539 pages += 1;
3540 page += 1;
3541 }
3542 Ok(FieldAnswer {
3543 value: AnswerValue::Text(out),
3544 basis: Basis::Heuristic,
3545 selector: req.selector.canonical(),
3546 representation: req.representation.name().to_string(),
3547 source_span: None,
3548 provenance: format!("pdf;pages={pages}"),
3549 dependency_ids: Vec::new(),
3550 integrity_scope: IntegrityScope::None,
3551 exact: false,
3552 })
3553 }
3554
3555 #[cfg(feature = "docx")]
3558 fn common_docx(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3559 let profile = DocxExtractProfile::DEFAULT;
3560 match &req.selector {
3561 Selector::Metadata => self.docx_common_metadata(req, &profile),
3562 Selector::Text => self.docx_story_text(req, DocxStory::Main, &profile),
3563 Selector::Heading(i) => self.docx_common_heading(req, *i, &profile),
3564 Selector::Block(i) => self.docx_common_block(req, *i, &profile),
3565 Selector::Table(i) => self.docx_table(req, DocxStory::Main, *i, &profile),
3566 Selector::Cell { table, row, col } => {
3567 let cell = a1_ref(*col, *row);
3568 self.docx_cell(req, DocxStory::Main, *table, &cell, &profile)
3569 }
3570 Selector::Resource(i) => self.docx_common_resource(req, *i, &profile),
3571 Selector::Link(i) => self.docx_common_link(req, *i, &profile),
3572 Selector::SearchMatch(p) => self.docx_find(req, DocxStory::Main, p, &profile),
3573 other => Err(Error::unsupported_feature(format!(
3574 "DOCX does not support common selector {}",
3575 other.canonical()
3576 ))),
3577 }
3578 }
3579
3580 #[cfg(not(feature = "docx"))]
3581 fn common_docx(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
3582 Err(Error::unsupported_feature(
3583 "DOCX observations require a build with the docx feature",
3584 ))
3585 }
3586
3587 #[cfg(feature = "docx")]
3588 fn docx_common_metadata(
3589 &mut self,
3590 req: &ObserveRequest,
3591 profile: &DocxExtractProfile,
3592 ) -> Result<FieldAnswer> {
3593 let v = self.docx_story_view(DocxStory::Main, profile)?;
3594 let json = format!(
3595 concat!(
3596 "{{\"format\":\"docx\",\"story\":\"main\",\"part\":\"{}\",\"ordinal\":{},",
3597 "\"root\":\"{}\",\"blocks\":{},\"paragraphs\":{},\"tables\":{},",
3598 "\"hyperlinks\":{},\"bookmarks\":{},\"resources\":{},\"sections\":{},",
3599 "\"profile\":\"{}\"}}"
3600 ),
3601 json_escape(&v.part.name),
3602 v.part.ordinal,
3603 json_escape(&v.model.root_local),
3604 v.model.blocks.len(),
3605 v.model.paragraphs().count(),
3606 v.model.tables().count(),
3607 v.model.hyperlinks.len(),
3608 v.model.bookmarks.len(),
3609 v.model.resources.len(),
3610 v.model.section_count,
3611 profile.fingerprint(),
3612 );
3613 let provenance = format!("docx;story=main;part={}", v.part.name);
3614 Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
3615 }
3616
3617 #[cfg(feature = "docx")]
3618 fn docx_common_heading(
3619 &mut self,
3620 req: &ObserveRequest,
3621 ordinal: u32,
3622 profile: &DocxExtractProfile,
3623 ) -> Result<FieldAnswer> {
3624 let v = self.docx_story_view(DocxStory::Main, profile)?;
3625 let p = v
3626 .model
3627 .paragraphs()
3628 .filter(|p| p.heading_level.is_some())
3629 .nth(ordinal as usize)
3630 .ok_or_else(|| {
3631 Error::unsupported_feature(format!("DOCX main story has no heading {ordinal}"))
3632 })?;
3633 let index = p.index;
3634 let text = p.text.clone();
3635 let level = p.heading_level;
3636 let style = p.style_id.clone();
3637 let value = match req.representation {
3638 Representation::Text => AnswerValue::Text(text),
3639 Representation::Metadata => AnswerValue::Json(format!(
3640 "{{\"story\":\"main\",\"heading\":{ordinal},\"paragraph\":{index},\"level\":{},\"style\":{},\"text_len\":{}}}",
3641 opt_u8_json(level),
3642 opt_str_json(style.as_deref()),
3643 text.len()
3644 )),
3645 _ => return Err(unsupported_common(req)),
3646 };
3647 let provenance = format!(
3648 "docx;story=main;part={};heading={ordinal};paragraph={index};profile={}",
3649 v.part.name,
3650 profile.fingerprint()
3651 );
3652 Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
3653 }
3654
3655 #[cfg(feature = "docx")]
3656 fn docx_common_block(
3657 &mut self,
3658 req: &ObserveRequest,
3659 ordinal: u32,
3660 profile: &DocxExtractProfile,
3661 ) -> Result<FieldAnswer> {
3662 use crate::adapter::docx::wml::Block as WmlBlock;
3663 let v = self.docx_story_view(DocxStory::Main, profile)?;
3664 let b = v.model.blocks.get(ordinal as usize).ok_or_else(|| {
3665 Error::unsupported_feature(format!("DOCX main story has no block {ordinal}"))
3666 })?;
3667 let (kind, text, detail) = match b {
3668 WmlBlock::Paragraph(p) => (
3669 "paragraph",
3670 p.text.clone(),
3671 format!(
3672 "\"paragraph\":{},\"level\":{}",
3673 p.index,
3674 opt_u8_json(p.heading_level)
3675 ),
3676 ),
3677 WmlBlock::Table(t) => (
3678 "table",
3679 t.text(),
3680 format!("\"table\":{},\"rows\":{}", t.index, t.rows.len()),
3681 ),
3682 };
3683 let value = match req.representation {
3684 Representation::Text => AnswerValue::Text(text),
3685 Representation::Metadata => AnswerValue::Json(format!(
3686 "{{\"story\":\"main\",\"block\":{ordinal},\"kind\":\"{kind}\",{detail},\"text_len\":{}}}",
3687 text.len()
3688 )),
3689 _ => return Err(unsupported_common(req)),
3690 };
3691 let provenance = format!(
3692 "docx;story=main;part={};block={ordinal};profile={}",
3693 v.part.name,
3694 profile.fingerprint()
3695 );
3696 Ok(self.docx_answer(req, value, provenance, v.span, v.deps))
3697 }
3698
3699 #[cfg(feature = "docx")]
3700 fn docx_common_resource(
3701 &mut self,
3702 req: &ObserveRequest,
3703 ordinal: u32,
3704 profile: &DocxExtractProfile,
3705 ) -> Result<FieldAnswer> {
3706 let v = self.docx_story_view(DocxStory::Main, profile)?;
3707 let rel = v.model.resources.get(ordinal as usize).ok_or_else(|| {
3708 Error::unsupported_feature(format!("DOCX main story has no resource {ordinal}"))
3709 })?;
3710 let json = format!(
3711 "{{\"story\":\"main\",\"resource\":{ordinal},\"rel\":\"{}\"}}",
3712 json_escape(rel)
3713 );
3714 let provenance = format!(
3715 "docx;story=main;part={};resource={ordinal};profile={}",
3716 v.part.name,
3717 profile.fingerprint()
3718 );
3719 Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
3720 }
3721
3722 #[cfg(feature = "docx")]
3723 fn docx_common_link(
3724 &mut self,
3725 req: &ObserveRequest,
3726 ordinal: u32,
3727 profile: &DocxExtractProfile,
3728 ) -> Result<FieldAnswer> {
3729 let v = self.docx_story_view(DocxStory::Main, profile)?;
3730 let l = v.model.hyperlinks.get(ordinal as usize).ok_or_else(|| {
3731 Error::unsupported_feature(format!("DOCX main story has no hyperlink {ordinal}"))
3732 })?;
3733 let json = format!(
3734 "{{\"story\":\"main\",\"link\":{ordinal},\"text\":\"{}\",\"rel_id\":{},\"anchor\":{}}}",
3735 json_escape(&l.text),
3736 opt_str_json(l.rel_id.as_deref()),
3737 opt_str_json(l.anchor.as_deref())
3738 );
3739 let provenance = format!(
3740 "docx;story=main;part={};link={ordinal};profile={}",
3741 v.part.name,
3742 profile.fingerprint()
3743 );
3744 Ok(self.docx_answer(req, AnswerValue::Json(json), provenance, v.span, v.deps))
3745 }
3746
3747 #[cfg(feature = "epub")]
3750 fn common_epub(&mut self, req: &ObserveRequest) -> Result<FieldAnswer> {
3751 let profile = EpubExtractProfile::DEFAULT;
3752 match &req.selector {
3753 Selector::Metadata => self.epub_package(req),
3754 Selector::Text => self.epub_common_text(req, &profile),
3755 Selector::Heading(i) => self.epub_common_block(req, *i, true, &profile),
3756 Selector::Block(i) => self.epub_common_block(req, *i, false, &profile),
3757 Selector::Table(i) => self.epub_common_table(req, *i, &profile),
3758 Selector::Cell { table, row, col } => {
3759 self.epub_common_cell(req, *table, *row, *col, &profile)
3760 }
3761 Selector::Resource(i) => self.epub_common_resource(req, *i, &profile),
3762 Selector::Link(i) => self.epub_common_link(req, *i, &profile),
3763 Selector::SearchMatch(p) => self.epub_common_search(req, p, &profile),
3764 other => Err(Error::unsupported_feature(format!(
3765 "EPUB does not support common selector {}",
3766 other.canonical()
3767 ))),
3768 }
3769 }
3770
3771 #[cfg(not(feature = "epub"))]
3772 fn common_epub(&mut self, _req: &ObserveRequest) -> Result<FieldAnswer> {
3773 Err(Error::unsupported_feature(
3774 "EPUB observations require a build with the epub feature",
3775 ))
3776 }
3777
3778 #[cfg(feature = "epub")]
3779 fn epub_common_text(
3780 &mut self,
3781 req: &ObserveRequest,
3782 profile: &EpubExtractProfile,
3783 ) -> Result<FieldAnswer> {
3784 let doc = self.epub_package_doc()?;
3785 let order_len = doc.reading_order(profile).len() as u32;
3786 let mut out = String::new();
3787 let mut items: u64 = 0;
3788 for index in 0..order_len {
3789 let (model, _item, _span, _deps) = self.epub_content_view(index, profile)?;
3790 out.push_str(&model.text());
3791 if !out.ends_with('\n') {
3792 out.push('\n');
3793 }
3794 if out.len() as u64 > req.budget.max_output_bytes {
3795 return Err(Error::resource_limit(format!(
3796 "whole-document text exceeded the {}-byte budget",
3797 req.budget.max_output_bytes
3798 )));
3799 }
3800 items += 1;
3801 }
3802 let provenance = format!("epub;spine-items={items};profile={}", profile.fingerprint());
3803 Ok(self.epub_answer(req, AnswerValue::Text(out), provenance, None, Vec::new()))
3804 }
3805
3806 #[cfg(feature = "epub")]
3809 fn epub_common_block(
3810 &mut self,
3811 req: &ObserveRequest,
3812 ordinal: u32,
3813 headings_only: bool,
3814 profile: &EpubExtractProfile,
3815 ) -> Result<FieldAnswer> {
3816 let doc = self.epub_package_doc()?;
3817 let order_len = doc.reading_order(profile).len() as u32;
3818 let mut remaining = ordinal as usize;
3819 for index in 0..order_len {
3820 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3821 for (local, b) in model.blocks.iter().enumerate() {
3822 if headings_only && !matches!(b, crate::adapter::epub::Block::Heading { .. }) {
3823 continue;
3824 }
3825 if remaining == 0 {
3826 let text = b.text();
3827 let value = match req.representation {
3828 Representation::Text => AnswerValue::Text(text),
3829 Representation::Metadata => {
3830 AnswerValue::Json(epub_block_json(local as u32, b))
3831 }
3832 _ => return Err(unsupported_common(req)),
3833 };
3834 let provenance = format!(
3835 "epub;spine={index};part={};block={local};ordinal={ordinal};profile={}",
3836 item.resolved.as_deref().unwrap_or(""),
3837 profile.fingerprint()
3838 );
3839 return Ok(self.epub_answer(req, value, provenance, span, deps));
3840 }
3841 remaining -= 1;
3842 }
3843 }
3844 let what = if headings_only { "heading" } else { "block" };
3845 Err(Error::unsupported_feature(format!(
3846 "EPUB reading order has no {what} {ordinal}"
3847 )))
3848 }
3849
3850 #[cfg(feature = "epub")]
3851 fn epub_common_table(
3852 &mut self,
3853 req: &ObserveRequest,
3854 ordinal: u32,
3855 profile: &EpubExtractProfile,
3856 ) -> Result<FieldAnswer> {
3857 let doc = self.epub_package_doc()?;
3858 let order_len = doc.reading_order(profile).len() as u32;
3859 let mut remaining = ordinal as usize;
3860 for index in 0..order_len {
3861 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3862 for (local, b) in model.blocks.iter().enumerate() {
3863 if !matches!(b, crate::adapter::epub::Block::Table { .. }) {
3864 continue;
3865 }
3866 if remaining == 0 {
3867 let value = match req.representation {
3868 Representation::Text => AnswerValue::Text(b.text()),
3869 Representation::Metadata => {
3870 AnswerValue::Json(epub_block_json(local as u32, b))
3871 }
3872 _ => return Err(unsupported_common(req)),
3873 };
3874 let provenance = format!(
3875 "epub;spine={index};part={};table={local};ordinal={ordinal};profile={}",
3876 item.resolved.as_deref().unwrap_or(""),
3877 profile.fingerprint()
3878 );
3879 return Ok(self.epub_answer(req, value, provenance, span, deps));
3880 }
3881 remaining -= 1;
3882 }
3883 }
3884 Err(Error::unsupported_feature(format!(
3885 "EPUB reading order has no table {ordinal}"
3886 )))
3887 }
3888
3889 #[cfg(feature = "epub")]
3890 fn epub_common_cell(
3891 &mut self,
3892 req: &ObserveRequest,
3893 table: u32,
3894 row: u32,
3895 col: u32,
3896 profile: &EpubExtractProfile,
3897 ) -> Result<FieldAnswer> {
3898 use crate::adapter::epub::Block as EBlock;
3899 let doc = self.epub_package_doc()?;
3900 let order_len = doc.reading_order(profile).len() as u32;
3901 let mut remaining = table as usize;
3902 for index in 0..order_len {
3903 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3904 for (local, b) in model.blocks.iter().enumerate() {
3905 let EBlock::Table { rows } = b else { continue };
3906 if remaining > 0 {
3907 remaining -= 1;
3908 continue;
3909 }
3910 let r = rows.get(row as usize).ok_or_else(|| {
3911 Error::unsupported_feature(format!("EPUB table {table} has no row {row}"))
3912 })?;
3913 let c = r.cells.get(col as usize).ok_or_else(|| {
3914 Error::unsupported_feature(format!(
3915 "EPUB table {table} row {row} has no cell {col}"
3916 ))
3917 })?;
3918 let value = match req.representation {
3919 Representation::Text => AnswerValue::Text(c.text.clone()),
3920 Representation::Metadata => {
3921 AnswerValue::Json(epub_cell_json(table, row, col, c))
3922 }
3923 _ => return Err(unsupported_common(req)),
3924 };
3925 let provenance = format!(
3926 "epub;spine={index};part={};table={local};row={row};col={col};profile={}",
3927 item.resolved.as_deref().unwrap_or(""),
3928 profile.fingerprint()
3929 );
3930 return Ok(self.epub_answer(req, value, provenance, span, deps));
3931 }
3932 }
3933 Err(Error::unsupported_feature(format!(
3934 "EPUB reading order has no table {table}"
3935 )))
3936 }
3937
3938 #[cfg(feature = "epub")]
3939 fn epub_common_resource(
3940 &mut self,
3941 req: &ObserveRequest,
3942 ordinal: u32,
3943 profile: &EpubExtractProfile,
3944 ) -> Result<FieldAnswer> {
3945 let doc = self.epub_package_doc()?;
3946 let resources: Vec<&ManifestItem> = doc
3947 .manifest
3948 .iter()
3949 .filter(|i| !is_document_media_type(&i.media_type))
3950 .collect();
3951 let item = resources
3952 .get(ordinal as usize)
3953 .copied()
3954 .ok_or_else(|| Error::unsupported_feature(format!("EPUB has no resource {ordinal}")))?;
3955 if req.representation == Representation::Metadata {
3956 let json = format!(
3957 concat!(
3958 "{{\"resource\":{},\"id\":\"{}\",\"href\":\"{}\",",
3959 "\"media_type\":\"{}\",\"member\":{},\"profile\":\"{}\"}}"
3960 ),
3961 ordinal,
3962 json_escape(&item.id),
3963 json_escape(&item.href),
3964 json_escape(&item.media_type),
3965 epub_opt_str(item.resolved.as_deref()),
3966 profile.fingerprint()
3967 );
3968 let provenance = format!(
3969 "epub;resource={ordinal};id={};profile={}",
3970 item.id,
3971 profile.fingerprint()
3972 );
3973 return Ok(self.epub_answer(
3974 req,
3975 AnswerValue::Json(json),
3976 provenance,
3977 None,
3978 Vec::new(),
3979 ));
3980 }
3981 self.epub_item_bytes(req, item)
3983 }
3984
3985 #[cfg(feature = "epub")]
3986 fn epub_common_link(
3987 &mut self,
3988 req: &ObserveRequest,
3989 ordinal: u32,
3990 profile: &EpubExtractProfile,
3991 ) -> Result<FieldAnswer> {
3992 let doc = self.epub_package_doc()?;
3993 let order_len = doc.reading_order(profile).len() as u32;
3994 let mut remaining = ordinal as usize;
3995 for index in 0..order_len {
3996 let (model, item, span, deps) = self.epub_content_view(index, profile)?;
3997 for (local, l) in model.links.iter().enumerate() {
3998 if remaining == 0 {
3999 let provenance = format!(
4000 "epub;spine={index};part={};link={local};ordinal={ordinal};profile={}",
4001 item.resolved.as_deref().unwrap_or(""),
4002 profile.fingerprint()
4003 );
4004 return Ok(self.epub_answer(
4005 req,
4006 AnswerValue::Json(epub_link_json(local as u32, l)),
4007 provenance,
4008 span,
4009 deps,
4010 ));
4011 }
4012 remaining -= 1;
4013 }
4014 }
4015 Err(Error::unsupported_feature(format!(
4016 "EPUB reading order has no link {ordinal}"
4017 )))
4018 }
4019
4020 #[cfg(feature = "epub")]
4021 fn epub_common_search(
4022 &mut self,
4023 req: &ObserveRequest,
4024 pattern: &str,
4025 profile: &EpubExtractProfile,
4026 ) -> Result<FieldAnswer> {
4027 let doc = self.epub_package_doc()?;
4028 let order_len = doc.reading_order(profile).len() as u32;
4029 let mut items: Vec<String> = Vec::new();
4030 let mut estimated: u64 = 0;
4031 for index in 0..order_len {
4032 let (model, item, _span, _deps) = self.epub_content_view(index, profile)?;
4033 let _ = item;
4034 for (local, b) in model.blocks.iter().enumerate() {
4035 let t = b.text();
4036 if t.contains(pattern) {
4037 estimated = estimated.saturating_add(t.len() as u64 + 64);
4038 if estimated > req.budget.max_output_bytes {
4039 return Err(Error::resource_limit(format!(
4040 "EPUB search exceeded the {}-byte budget",
4041 req.budget.max_output_bytes
4042 )));
4043 }
4044 items.push(format!(
4045 "{{\"spine\":{index},\"block\":{local},\"kind\":\"{}\",\"text\":\"{}\"}}",
4046 b.kind(),
4047 json_escape(&t)
4048 ));
4049 }
4050 }
4051 }
4052 let provenance = format!("epub;search;profile={}", profile.fingerprint());
4053 Ok(self.epub_answer(
4054 req,
4055 AnswerValue::Json(format!("[{}]", items.join(","))),
4056 provenance,
4057 None,
4058 Vec::new(),
4059 ))
4060 }
4061}
4062
4063#[cfg(any(feature = "docx", feature = "epub"))]
4066fn unsupported_common(req: &ObserveRequest) -> Error {
4067 Error::unsupported_feature(format!(
4068 "unsupported observation: selector {} with representation {}",
4069 req.selector.canonical(),
4070 req.representation.name()
4071 ))
4072}
4073
4074#[cfg(feature = "docx")]
4076fn a1_ref(col: u32, row: u32) -> String {
4077 let mut c = col + 1;
4078 let mut letters: Vec<char> = Vec::new();
4079 while c > 0 {
4080 let rem = ((c - 1) % 26) as u8;
4081 letters.push((b'A' + rem) as char);
4082 c = (c - 1) / 26;
4083 }
4084 letters.reverse();
4085 format!("{}{}", letters.into_iter().collect::<String>(), row + 1)
4086}
4087
4088#[cfg(feature = "epub")]
4090fn is_document_media_type(media_type: &str) -> bool {
4091 media_type == "application/xhtml+xml"
4092 || media_type == "application/oebps-package+xml"
4093 || media_type == "application/x-dtbncx+xml"
4094}
4095
4096pub(crate) fn derived_nodes(page: u32, page_content: NodeId) -> (SeedNode, SeedNode, SeedNode) {
4099 let ops = SeedNode::new(
4100 NodeKind::ContentOperators,
4101 0,
4102 u32_params(page),
4103 vec![page_content],
4104 "pdf:content-operators",
4105 );
4106 let text = SeedNode::new(
4107 NodeKind::TextRuns,
4108 0,
4109 Vec::new(),
4110 vec![ops.content_id()],
4111 "pdf:text-runs",
4112 );
4113 let preview = SeedNode::new(
4114 NodeKind::PagePreview,
4115 0,
4116 u32_params(page),
4117 vec![page_content],
4118 "pdf:page-preview",
4119 );
4120 (ops, text, preview)
4121}
4122
4123fn preview_stats(bytes: &[u8]) -> (u64, u64, u64) {
4125 let rendered = String::from_utf8_lossy(bytes);
4126 let mut text_bytes = 0u64;
4127 let mut draw_ops = 0u64;
4128 let mut path_ops = 0u64;
4129 for line in rendered.lines() {
4130 if let Some(v) = line.strip_prefix("text-bytes ") {
4131 text_bytes = v.trim().parse().unwrap_or(0);
4132 } else if let Some(v) = line.strip_prefix("draw-ops ") {
4133 draw_ops = v.trim().parse().unwrap_or(0);
4134 } else if let Some(v) = line.strip_prefix("path-ops ") {
4135 path_ops = v.trim().parse().unwrap_or(0);
4136 }
4137 }
4138 (text_bytes, draw_ops, path_ops)
4139}
4140
4141#[cfg(test)]
4142mod tests {
4143 use super::*;
4144 use crate::container::{Descriptor, ObjectSource};
4145 use crate::dra::{Op, Program};
4146 use crate::field::plan;
4147 use crate::store::FsSeedStore;
4148 use std::fs;
4149 use std::path::PathBuf;
4150
4151 fn temp_root(label: &str) -> PathBuf {
4152 let mut p = std::env::temp_dir();
4153 p.push(format!(
4154 "vole-observe-{label}-{}-{}",
4155 std::process::id(),
4156 std::time::SystemTime::now()
4157 .duration_since(std::time::UNIX_EPOCH)
4158 .unwrap()
4159 .as_nanos()
4160 ));
4161 p
4162 }
4163
4164 fn opaque_descriptor(source: &[u8]) -> Vec<u8> {
4165 let d = Descriptor {
4166 universe: crate::container::UNIVERSE.to_string(),
4167 source_format: crate::SOURCE_FORMAT_PDF,
4168 format_basis: "pdf:observe-test".to_string(),
4169 models: vec![],
4170 channels: vec![],
4171 objects: vec![ObjectSource::Inline(source.to_vec())],
4172 program: Program::new(vec![Op::EmitObject { object_id: 0 }]),
4173 observation_index: None,
4174 seek_directory: false,
4175 source_sha256: crate::integrity::sha256(source),
4176 source_len: source.len() as u64,
4177 };
4178 d.serialize().unwrap().0
4179 }
4180
4181 fn adler32(data: &[u8]) -> u32 {
4182 let mut a: u32 = 1;
4183 let mut b: u32 = 0;
4184 for &byte in data {
4185 a = (a + u32::from(byte)) % 65521;
4186 b = (b + a) % 65521;
4187 }
4188 (b << 16) | a
4189 }
4190
4191 fn zlib_stored(data: &[u8]) -> Vec<u8> {
4192 assert!(!data.is_empty());
4193 let mut out = vec![0x78, 0x01];
4194 let chunks: Vec<&[u8]> = data.chunks(0xFFFF).collect();
4195 for (i, chunk) in chunks.iter().enumerate() {
4196 let final_block = u8::from(i + 1 == chunks.len());
4197 out.push(final_block);
4198 let len = chunk.len() as u16;
4199 out.extend_from_slice(&len.to_le_bytes());
4200 out.extend_from_slice(&(!len).to_le_bytes());
4201 out.extend_from_slice(chunk);
4202 }
4203 out.extend_from_slice(&adler32(data).to_be_bytes());
4204 out
4205 }
4206
4207 struct PdfBuilder {
4208 buf: Vec<u8>,
4209 offsets: Vec<(u64, u64)>,
4210 }
4211
4212 impl PdfBuilder {
4213 fn new() -> Self {
4214 PdfBuilder {
4215 buf: Vec::new(),
4216 offsets: Vec::new(),
4217 }
4218 }
4219 fn text(&mut self, s: &str) {
4220 self.buf.extend_from_slice(s.as_bytes());
4221 }
4222 fn raw(&mut self, b: &[u8]) {
4223 self.buf.extend_from_slice(b);
4224 }
4225 fn obj(&mut self, number: u64, body: &[u8]) {
4226 self.offsets.push((number, self.buf.len() as u64));
4227 self.text(&format!("{number} 0 obj\n"));
4228 self.raw(body);
4229 self.text("\nendobj\n");
4230 }
4231 fn stream_obj(&mut self, number: u64, extra: &str, data: &[u8]) {
4232 self.offsets.push((number, self.buf.len() as u64));
4233 self.text(&format!(
4234 "{number} 0 obj\n<< /Length {}{extra} >>\nstream\n",
4235 data.len()
4236 ));
4237 self.raw(data);
4238 self.text("\nendstream\nendobj\n");
4239 }
4240 fn offset_of(&self, number: u64) -> u64 {
4241 self.offsets
4242 .iter()
4243 .find(|&&(n, _)| n == number)
4244 .map(|&(_, off)| off)
4245 .unwrap()
4246 }
4247 fn classic_trailer(&mut self, size: u64, extra: &str) {
4248 let xref = self.buf.len() as u64;
4249 self.text(&format!("xref\n0 {size}\n"));
4250 self.raw(b"0000000000 65535 f \n");
4251 for number in 1..size {
4252 let off = self.offset_of(number);
4253 self.text(&format!("{off:010} 00000 n \n"));
4254 }
4255 self.text(&format!(
4256 "trailer\n<< /Size {size}{extra} >>\nstartxref\n{xref}\n%%EOF\n"
4257 ));
4258 }
4259 }
4260
4261 fn fixture_pdf(with_image: bool) -> Vec<u8> {
4264 let content = b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n";
4265 let encoded = zlib_stored(content);
4266 let mut w = PdfBuilder::new();
4267 w.text("%PDF-1.5\n");
4268 w.obj(1, b"<< /Type /Catalog /Pages 2 0 R >>");
4269 w.obj(2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
4270 if with_image {
4271 w.obj(
4272 3,
4273 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 >>",
4274 );
4275 } else {
4276 w.obj(
4277 3,
4278 b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
4279 );
4280 }
4281 w.stream_obj(4, " /Filter /FlateDecode", &encoded);
4282 w.obj(5, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
4283 if with_image {
4284 let image = vec![0x80u8; 256 * 256];
4285 let image_encoded = zlib_stored(&image);
4286 w.stream_obj(
4287 6,
4288 " /Type /XObject /Subtype /Image /Width 256 /Height 256 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode",
4289 &image_encoded,
4290 );
4291 w.classic_trailer(7, " /Root 1 0 R");
4292 } else {
4293 w.classic_trailer(6, " /Root 1 0 R");
4294 }
4295 w.buf
4296 }
4297
4298 struct Fixture {
4299 root: PathBuf,
4300 store: FieldStore,
4301 field: FieldId,
4302 source: Vec<u8>,
4303 }
4304
4305 impl Fixture {
4306 fn new(label: &str, with_image: bool) -> Fixture {
4307 let root = temp_root(label);
4308 let mut store = FieldStore::open(&root).unwrap();
4309 let source = fixture_pdf(with_image);
4310 let descriptor = opaque_descriptor(&source);
4311 let report = ingest::ingest_pdf(&mut store, &descriptor, Limits::DEFAULT).unwrap();
4312 Fixture {
4313 root,
4314 store,
4315 field: report.field,
4316 source,
4317 }
4318 }
4319 }
4320
4321 impl Drop for Fixture {
4322 fn drop(&mut self) {
4323 fs::remove_dir_all(&self.root).ok();
4324 }
4325 }
4326
4327 fn observe_req(
4328 fx: &mut Fixture,
4329 selector: Selector,
4330 representation: Representation,
4331 ) -> (FieldAnswer, ObserveStats) {
4332 let req = ObserveRequest::new(selector, representation);
4333 let (answer, stats, _field) =
4334 observe(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
4335 (answer, stats)
4336 }
4337
4338 #[test]
4339 fn document_full_document_is_exact() {
4340 let mut fx = Fixture::new("full", false);
4341 let (answer, stats) =
4342 observe_req(&mut fx, Selector::Document, Representation::FullDocument);
4343 assert_eq!(answer.value, AnswerValue::Bytes(fx.source.clone()));
4344 assert_eq!(answer.basis, Basis::DirectlyObserved);
4345 assert_eq!(answer.integrity_scope, IntegrityScope::WholeSource);
4346 assert!(answer.exact);
4347 assert_eq!(answer.source_span, Some((0, fx.source.len() as u64)));
4348 assert_eq!(stats.bytes_returned, fx.source.len() as u64);
4349 }
4350
4351 #[test]
4352 fn byte_range_returns_exact_bytes() {
4353 let mut fx = Fixture::new("range", false);
4354 let (answer, _) = observe_req(
4355 &mut fx,
4356 Selector::ByteRange { offset: 9, len: 8 },
4357 Representation::ExactBytes,
4358 );
4359 assert_eq!(answer.value, AnswerValue::Bytes(fx.source[9..17].to_vec()));
4360 assert_eq!(answer.source_span, Some((9, 17)));
4361 assert!(answer.exact);
4362 assert_eq!(answer.integrity_scope, IntegrityScope::Node);
4363 }
4364
4365 #[test]
4366 fn page_text_is_heuristic_and_nonempty() {
4367 let mut fx = Fixture::new("text", false);
4368 let (answer, _) = observe_req(&mut fx, Selector::Page(1), Representation::Text);
4369 match &answer.value {
4370 AnswerValue::Text(t) => assert!(t.contains("Hello"), "got {t:?}"),
4371 other => panic!("expected text, got {other:?}"),
4372 }
4373 assert_eq!(answer.basis, Basis::Heuristic);
4374 assert!(!answer.exact);
4375 }
4376
4377 #[test]
4383 fn page_structure_procedural_closure_excludes_image_seed_node() {
4384 let mut fx = Fixture::new("structure", true);
4385 let (answer, stats) = observe_req(&mut fx, Selector::Page(1), Representation::Structure);
4386 match &answer.value {
4387 AnswerValue::Json(j) => {
4388 assert!(j.contains("\"page\":1"), "got {j}");
4389 assert!(j.contains("\"content_streams\":[4]"), "got {j}");
4390 }
4391 other => panic!("expected json, got {other:?}"),
4392 }
4393 assert_eq!(answer.basis, Basis::DeterministicallyDerived);
4394 assert!(!answer.exact);
4395 assert!(
4397 stats.seed_bytes_read < 16 * 1024,
4398 "structure observation read {} seed bytes (expected < 16384)",
4399 stats.seed_bytes_read
4400 );
4401 assert!(
4404 stats.descriptor_bytes_read > 0,
4405 "the descriptor read must be accounted, not hidden"
4406 );
4407 assert!(
4408 stats.bytes_read >= stats.descriptor_bytes_read,
4409 "bytes_read {} must include descriptor_bytes_read {}",
4410 stats.bytes_read,
4411 stats.descriptor_bytes_read
4412 );
4413 assert_eq!(
4414 stats.bytes_read,
4415 stats
4416 .descriptor_bytes_read
4417 .saturating_add(stats.manifest_bytes_read)
4418 .saturating_add(stats.index_bytes_read)
4419 .saturating_add(stats.seed_bytes_read),
4420 "bytes_read must be the exact sum of the four physical classes"
4421 );
4422 }
4423
4424 #[test]
4425 fn plan_is_pure_and_deterministic() {
4426 let fx = Fixture::new("plan", false);
4427 let req = ObserveRequest::new(Selector::Page(1), Representation::Text);
4428 let field = Field::open(&fx.store, &fx.field, Limits::DEFAULT).unwrap();
4429 let before = fx.store.seeds().list_nodes().unwrap().len();
4430 let a = plan::plan(field.manifest(), &fx.store, &req).unwrap();
4431 let b = plan::plan(field.manifest(), &fx.store, &req).unwrap();
4432 assert_eq!(a, b);
4433 let after = fx.store.seeds().list_nodes().unwrap().len();
4434 assert_eq!(before, after, "plan must not add seed nodes");
4435 }
4436
4437 #[test]
4438 fn explain_json_keys_and_unsupported_pair() {
4439 use crate::field::explain;
4440 let mut fx = Fixture::new("explain", false);
4441 let req = ObserveRequest::new(Selector::Document, Representation::FullDocument);
4442 let (plan, actual) =
4443 explain::explain_analyze(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
4444 assert_eq!(
4445 plan.json,
4446 "{\"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\":[]}"
4447 );
4448 let actual_json = actual.to_json();
4449 let mut keys = top_level_keys(&actual_json);
4450 keys.sort();
4451 let mut expected = vec![
4452 "adapter",
4453 "basis",
4454 "bytes_read",
4455 "bytes_returned",
4456 "deepened",
4457 "descriptor_bytes_read",
4458 "descriptor_read_mode",
4459 "exact",
4460 "format",
4461 "index_bytes_read",
4462 "index_nodes_read",
4463 "inverse_work_units",
4464 "manifest_bytes_read",
4465 "member_decodes",
4466 "nodes_id_shared",
4467 "seed_bytes_read",
4468 "seed_nodes_fetched",
4469 "seed_nodes_materialized",
4470 "shared_resource_ids",
4471 "wall_micros",
4472 "whole_source_materialized",
4473 "xml_parses",
4474 ];
4475 expected.sort_unstable();
4476 assert_eq!(keys, expected, "actual json keys: {actual_json}");
4477
4478 let bad = ObserveRequest::new(Selector::Document, Representation::Text);
4480 let err = observe(&mut fx.store, &fx.field, &bad, Limits::DEFAULT).unwrap_err();
4481 assert_eq!(err.class(), crate::ErrorClass::UnsupportedFeature);
4482 let field = Field::open(&fx.store, &fx.field, Limits::DEFAULT).unwrap();
4483 let perr = plan::plan(field.manifest(), &fx.store, &bad).unwrap_err();
4484 assert_eq!(perr.class(), crate::ErrorClass::UnsupportedFeature);
4485 }
4486
4487 #[test]
4488 fn budget_yields_resource_limit_not_truncation() {
4489 let mut fx = Fixture::new("budget", false);
4490 let req = ObserveRequest {
4491 selector: Selector::Document,
4492 representation: Representation::FullDocument,
4493 budget: ObserveBudget {
4494 max_output_bytes: 4,
4495 max_nodes: 1 << 20,
4496 },
4497 use_cache: true,
4498 };
4499 let err = observe(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap_err();
4500 assert_eq!(err.class(), crate::ErrorClass::ResourceLimit);
4501 }
4502
4503 #[test]
4504 fn find_returns_matching_line() {
4505 let mut fx = Fixture::new("find", false);
4506 let (answer, _) = observe_req(
4507 &mut fx,
4508 Selector::TextMatch("Hello".to_string()),
4509 Representation::Text,
4510 );
4511 match &answer.value {
4512 AnswerValue::Json(j) => {
4513 assert!(j.contains("\"page\":1"), "got {j}");
4514 assert!(j.contains("Hello"), "got {j}");
4515 }
4516 other => panic!("expected json, got {other:?}"),
4517 }
4518 assert_eq!(answer.basis, Basis::Heuristic);
4519 }
4520
4521 #[test]
4522 fn preview_is_deterministic() {
4523 let mut fx = Fixture::new("preview", false);
4524 let (a, _) = observe_req(&mut fx, Selector::Page(1), Representation::Preview);
4525 let (b, _) = observe_req(&mut fx, Selector::Page(1), Representation::Preview);
4526 assert_eq!(a.value, b.value);
4527 match &a.value {
4528 AnswerValue::Bytes(bytes) => {
4529 assert!(String::from_utf8_lossy(bytes).contains("VOLE-PREVIEW v1"))
4530 }
4531 other => panic!("expected preview bytes, got {other:?}"),
4532 }
4533 }
4534
4535 #[test]
4536 fn decoded_and_operators_streams_resolve() {
4537 let mut fx = Fixture::new("decoded", false);
4538 let (decoded, _) = observe_req(&mut fx, Selector::Stream(4), Representation::DecodedBytes);
4539 match &decoded.value {
4540 AnswerValue::Bytes(b) => {
4541 assert_eq!(b, b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n");
4542 }
4543 other => panic!("expected bytes, got {other:?}"),
4544 }
4545 assert_eq!(decoded.basis, Basis::DeterministicallyDerived);
4546 let (ops, _) = observe_req(&mut fx, Selector::Stream(4), Representation::Operators);
4547 assert!(matches!(ops.value, AnswerValue::Bytes(ref b) if !b.is_empty()));
4548 }
4549
4550 #[test]
4552 fn observation_byte_classes_sum_and_are_all_charged() {
4553 let mut fx = Fixture::new("io-sum", false);
4554 let (_, stats) = observe_req(&mut fx, Selector::Page(1), Representation::Structure);
4555 assert!(
4556 stats.descriptor_bytes_read > 0,
4557 "descriptor bytes: {stats:?}"
4558 );
4559 assert!(stats.manifest_bytes_read > 0, "manifest bytes: {stats:?}");
4560 assert!(stats.index_bytes_read > 0, "index bytes: {stats:?}");
4561 assert!(stats.seed_bytes_read > 0, "seed bytes: {stats:?}");
4562 assert_eq!(
4563 stats.bytes_read,
4564 stats
4565 .descriptor_bytes_read
4566 .saturating_add(stats.manifest_bytes_read)
4567 .saturating_add(stats.index_bytes_read)
4568 .saturating_add(stats.seed_bytes_read),
4569 "bytes_read must equal the class sum: {stats:?}"
4570 );
4571 }
4572
4573 #[test]
4577 fn explain_analyze_opens_the_descriptor_once() {
4578 use crate::field::explain;
4579 let mut fx = Fixture::new("opens-once", false);
4580
4581 let req = ObserveRequest::new(Selector::Document, Representation::Metadata);
4583 let before = fx.store.io().descriptor_reads();
4584 let (_, actual) =
4585 explain::explain_analyze(&mut fx.store, &fx.field, &req, Limits::DEFAULT).unwrap();
4586 assert_eq!(
4587 fx.store.io().descriptor_reads() - before,
4588 1,
4589 "explain_analyze must open the descriptor exactly once"
4590 );
4591 assert!(
4592 actual.stats.descriptor_bytes_read > 0,
4593 "the single open must still be accounted: {:?}",
4594 actual.stats
4595 );
4596
4597 let page = ObserveRequest::new(Selector::Page(1), Representation::Text);
4600 let before = fx.store.io().descriptor_reads();
4601 let (_, page_actual) =
4602 explain::explain_analyze(&mut fx.store, &fx.field, &page, Limits::DEFAULT).unwrap();
4603 assert_eq!(
4604 fx.store.io().descriptor_reads() - before,
4605 1,
4606 "a cold promotion must not re-open the field for its manifest"
4607 );
4608 assert!(page_actual.stats.deepened, "the cold page must promote");
4609 assert!(page_actual.stats.descriptor_bytes_read > 0);
4610 }
4611
4612 struct BoundedSeedStore {
4616 inner: FsSeedStore,
4617 fetches: Cell<u64>,
4618 limit: u64,
4619 }
4620
4621 impl SeedStore for BoundedSeedStore {
4622 fn put_node(&mut self, canonical: &[u8]) -> Result<NodeId> {
4623 self.inner.put_node(canonical)
4624 }
4625
4626 fn get_node(&self, id: &NodeId) -> Result<Vec<u8>> {
4627 let n = self.fetches.get() + 1;
4628 assert!(
4629 n <= self.limit,
4630 "get_node #{n} exceeds the {}-fetch bound: the store was enumerated",
4631 self.limit
4632 );
4633 self.fetches.set(n);
4634 self.inner.get_node(id)
4635 }
4636
4637 fn get_node_range(&self, id: &NodeId, offset: u64, len: u64) -> Result<Vec<u8>> {
4638 let n = self.fetches.get() + 1;
4639 assert!(
4640 n <= self.limit,
4641 "get_node_range #{n} exceeds the {}-fetch bound: the store was enumerated",
4642 self.limit
4643 );
4644 self.fetches.set(n);
4645 self.inner.get_node_range(id, offset, len)
4646 }
4647
4648 fn contains_node(&self, id: &NodeId) -> Result<bool> {
4649 self.inner.contains_node(id)
4650 }
4651
4652 fn list_nodes(&self) -> Result<Vec<(NodeId, u64)>> {
4653 panic!("an observation must never enumerate the seed store")
4654 }
4655 }
4656
4657 #[test]
4658 fn decoded_stream_resolves_without_enumerating_the_store() {
4659 let mut fx = Fixture::new("no-scan", true);
4660 for i in 0..256u32 {
4662 let decoy = SeedNode::new(
4663 NodeKind::PdfObject,
4664 1,
4665 u32_params(10_000 + i),
4666 Vec::new(),
4667 "decoy",
4668 );
4669 fx.store
4670 .seeds_mut()
4671 .put_node(&decoy.encode_canonical())
4672 .unwrap();
4673 }
4674 let total = fx.store.seeds().list_nodes().unwrap().len() as u64;
4675 assert!(total > 200, "expected many seed nodes, got {total}");
4676
4677 let field = Field::open(&fx.store, &fx.field, Limits::DEFAULT).unwrap();
4678 let io = fx.store.io().handle();
4679 let seeds = CountingSeedStore::new(BoundedSeedStore {
4680 inner: FsSeedStore::open_with_io(fx.store.root(), io.handle()).unwrap(),
4681 fetches: Cell::new(0),
4682 limit: 8,
4683 });
4684 let istore = FsIndexStore::open_with_io(fx.store.root(), io.handle()).unwrap();
4685 let req = ObserveRequest::new(Selector::Stream(4), Representation::DecodedBytes);
4686 let (answer, stats, _) = observe_with_stores(
4687 &mut fx.store,
4688 FieldView::from_field(&field),
4689 &req,
4690 Limits::DEFAULT,
4691 Instant::now(),
4692 seeds,
4693 istore,
4694 )
4695 .unwrap();
4696 match &answer.value {
4697 AnswerValue::Bytes(b) => {
4698 assert_eq!(b, b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n")
4699 }
4700 other => panic!("expected bytes, got {other:?}"),
4701 }
4702 assert!(
4703 stats.seed_nodes_fetched <= 8,
4704 "fetched {} seed nodes for one decoded stream",
4705 stats.seed_nodes_fetched
4706 );
4707 assert!(
4708 stats.seed_nodes_fetched < total,
4709 "must not enumerate the {total}-node store; fetched {}",
4710 stats.seed_nodes_fetched
4711 );
4712 }
4713
4714 pub(super) fn top_level_keys(json: &str) -> Vec<String> {
4716 let b = json.as_bytes();
4717 let mut keys = Vec::new();
4718 let mut depth = 0i32;
4719 let mut in_str = false;
4720 let mut esc = false;
4721 let mut i = 0;
4722 while i < b.len() {
4723 let c = b[i];
4724 if in_str {
4725 if esc {
4726 esc = false;
4727 } else if c == b'\\' {
4728 esc = true;
4729 } else if c == b'"' {
4730 in_str = false;
4731 if depth == 1 && b.get(i + 1) == Some(&b':') {
4732 let mut j = i;
4733 while j > 0 {
4734 j -= 1;
4735 if b[j] == b'"' {
4736 keys.push(String::from_utf8_lossy(&b[j + 1..i]).into_owned());
4737 break;
4738 }
4739 }
4740 }
4741 }
4742 } else {
4743 match c {
4744 b'"' => in_str = true,
4745 b'{' | b'[' => depth += 1,
4746 b'}' | b']' => depth -= 1,
4747 _ => {}
4748 }
4749 }
4750 i += 1;
4751 }
4752 keys
4753 }
4754}