1#[cfg(not(feature = "std"))]
30use alloc::{
31 string::{String, ToString},
32 vec,
33 vec::Vec,
34};
35
36use crate::{
37 annotation::{Annotation, AnnotationError, MapArea},
38 bzz::bzz_decode,
39 dirm::{DirmComponent, DirmComponentKind, DirmPayload},
40 error::{BzzError, IffError, Iw44Error, Jb2Error},
41 iff::{IffChunk, parse_form, parse_form_body},
42 info::PageInfo,
43 iw44::Iw44Image,
44 jb2::Jb2Dict,
45 metadata::{DjVuMetadata, MetadataError},
46 pixmap::Pixmap,
47 text::{TextError, TextLayer},
48};
49
50#[cfg(not(feature = "std"))]
51use alloc::sync::Arc;
52#[cfg(feature = "std")]
53use std::sync::Arc;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub enum ComponentKind {
59 Page,
61 Shared,
63 Thumbnail,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
70pub struct ComponentId {
71 pub name: String,
73 pub kind: ComponentKind,
75}
76
77impl ComponentId {
78 pub fn new(name: impl Into<String>, kind: ComponentKind) -> Self {
80 Self {
81 name: name.into(),
82 kind,
83 }
84 }
85}
86
87#[derive(Debug, Clone, thiserror::Error)]
89#[non_exhaustive]
90pub enum ComponentResolveError {
91 #[error("indirect component {component:?} is missing")]
93 Missing {
94 component: ComponentId,
96 },
97
98 #[error("failed to resolve indirect component {component:?}: {reason}")]
100 Failed {
101 component: ComponentId,
103 reason: String,
105 },
106}
107
108pub trait ComponentResolver {
115 fn resolve(&self, component: &ComponentId) -> Result<Vec<u8>, ComponentResolveError>;
117}
118
119impl<F> ComponentResolver for F
120where
121 F: Fn(&ComponentId) -> Result<Vec<u8>, ComponentResolveError>,
122{
123 fn resolve(&self, component: &ComponentId) -> Result<Vec<u8>, ComponentResolveError> {
124 self(component)
125 }
126}
127
128#[derive(Debug, thiserror::Error)]
132#[non_exhaustive]
133pub enum DocError {
134 #[error("IFF error: {0}")]
136 Iff(#[from] IffError),
137
138 #[error("BZZ error: {0}")]
140 Bzz(#[from] BzzError),
141
142 #[error("IW44 error: {0}")]
144 Iw44(#[from] Iw44Error),
145
146 #[error("JB2 error: {0}")]
148 Jb2(#[from] Jb2Error),
149
150 #[error("not a DjVu file: found form type {0:?}")]
152 NotDjVu([u8; 4]),
153
154 #[error("missing required chunk: {0}")]
156 MissingChunk(&'static str),
157
158 #[error("malformed DjVu document: {0}")]
160 Malformed(&'static str),
161
162 #[error("failed to resolve indirect page '{0}'")]
164 IndirectResolve(String),
165
166 #[error("component resolution failed: {0}")]
168 ComponentResolve(#[from] ComponentResolveError),
169
170 #[error("indirect component {component:?} has FORM:{found:?}, expected kind {expected:?}")]
172 ComponentKindMismatch {
173 component: ComponentId,
175 found: [u8; 4],
177 expected: ComponentKind,
179 },
180
181 #[error("page index {index} is out of range (document has {count} pages)")]
183 PageOutOfRange { index: usize, count: usize },
184
185 #[error("invalid UTF-8 in DjVu metadata")]
190 InvalidUtf8,
191
192 #[error("indirect DjVu document requires a resolver callback")]
194 NoResolver,
195
196 #[cfg(feature = "std")]
198 #[error("I/O error: {0}")]
199 Io(#[from] std::io::Error),
200
201 #[error("Smmr decode error: {0}")]
203 Smmr(String),
204
205 #[error("text layer error: {0}")]
207 Text(#[from] TextError),
208
209 #[error("annotation error: {0}")]
211 Annotation(#[from] AnnotationError),
212
213 #[error("metadata error: {0}")]
215 Metadata(#[from] MetadataError),
216
217 #[error("{0}")]
219 ResourceLimit(#[from] crate::resource_limits::ResourceLimitExceeded),
220}
221
222#[derive(Debug, Clone)]
226#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
227pub struct DjVuBookmark {
228 pub title: String,
230 pub url: String,
232 pub children: Vec<DjVuBookmark>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
241#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
242pub struct ComponentDirectoryEntry {
243 pub kind: char,
245 pub id: String,
247}
248
249#[derive(Debug, Clone)]
253struct RawChunk {
254 id: [u8; 4],
255 data: Vec<u8>,
256}
257
258#[cfg(feature = "std")]
263pub(crate) type Backing = Arc<dyn AsRef<[u8]> + Send + Sync>;
264
265#[cfg(feature = "std")]
267fn backing_bytes(b: &Backing) -> &[u8] {
268 (**b).as_ref()
269}
270
271#[cfg(feature = "std")]
280enum ChunkStore {
281 Eager(Vec<RawChunk>),
282 Lazy {
283 backing: Backing,
284 range: core::ops::Range<usize>,
285 cache: std::sync::OnceLock<Vec<RawChunk>>,
286 },
287}
288
289#[cfg(feature = "std")]
290impl ChunkStore {
291 fn get(&self) -> &[RawChunk] {
295 match self {
296 ChunkStore::Eager(v) => v,
297 ChunkStore::Lazy {
298 backing,
299 range,
300 cache,
301 } => cache.get_or_init(|| {
302 let Some(sub) = backing_bytes(backing).get(range.clone()) else {
303 return Vec::new();
304 };
305 match parse_sub_form(sub) {
306 Ok(chunks) => chunks
307 .iter()
308 .map(|c| RawChunk {
309 id: c.id,
310 data: c.data.to_vec(),
311 })
312 .collect(),
313 Err(_) => Vec::new(),
314 }
315 }),
316 }
317 }
318}
319
320#[cfg(feature = "std")]
321impl Clone for ChunkStore {
322 fn clone(&self) -> Self {
323 match self {
324 ChunkStore::Eager(v) => ChunkStore::Eager(v.clone()),
325 ChunkStore::Lazy { backing, range, .. } => ChunkStore::Lazy {
327 backing: backing.clone(),
328 range: range.clone(),
329 cache: std::sync::OnceLock::new(),
330 },
331 }
332 }
333}
334
335fn decode_paired_payload(z: Option<&[u8]>, a: Option<&[u8]>) -> Result<Option<Vec<u8>>, BzzError> {
346 if let Some(z) = z {
347 return if z.is_empty() {
348 Ok(None)
349 } else {
350 Ok(Some(bzz_decode(z)?))
351 };
352 }
353 if let Some(a) = a {
354 return Ok(if a.is_empty() { None } else { Some(a.to_vec()) });
355 }
356 Ok(None)
357}
358
359#[cfg(feature = "std")]
387pub(crate) struct SharedDict {
388 raw: Vec<u8>,
389 decoded: std::sync::OnceLock<Option<Jb2Dict>>,
390}
391
392#[cfg(feature = "std")]
393impl SharedDict {
394 pub(crate) fn new(raw: Vec<u8>) -> Self {
396 Self {
397 raw,
398 decoded: std::sync::OnceLock::new(),
399 }
400 }
401
402 fn get(&self) -> Option<&Jb2Dict> {
405 self.decoded
406 .get_or_init(|| crate::jb2::decode_dict(&self.raw, None).ok())
407 .as_ref()
408 }
409
410 fn raw_len(&self) -> usize {
412 self.raw.len()
413 }
414}
415
416pub struct DjVuPage {
417 info: PageInfo,
419 #[cfg(feature = "std")]
423 chunks: ChunkStore,
424 #[cfg(not(feature = "std"))]
425 chunks: Vec<RawChunk>,
426 index: usize,
428 #[cfg(feature = "std")]
436 shared_djbz: Option<Arc<SharedDict>>,
437 #[cfg(not(feature = "std"))]
438 shared_djbz: Option<Vec<u8>>,
439 #[cfg(feature = "std")]
446 render_layers: std::sync::OnceLock<Arc<crate::djvu_render::PageLayers>>,
447 resource_limits: Option<crate::resource_limits::ResourceLimits>,
449}
450
451impl Clone for DjVuPage {
452 fn clone(&self) -> Self {
453 DjVuPage {
454 info: self.info.clone(),
455 chunks: self.chunks.clone(),
456 index: self.index,
457 shared_djbz: self.shared_djbz.clone(),
458 #[cfg(feature = "std")]
462 render_layers: std::sync::OnceLock::new(),
463 resource_limits: self.resource_limits,
464 }
465 }
466}
467
468impl core::fmt::Debug for DjVuPage {
469 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
470 #[cfg(feature = "std")]
471 let dbz_len = self.shared_djbz.as_ref().map(|v| v.raw_len());
472 #[cfg(not(feature = "std"))]
473 let dbz_len = self.shared_djbz.as_ref().map(|v| v.len());
474 f.debug_struct("DjVuPage")
475 .field("info", &self.info)
476 .field("chunks", &self.chunk_slice())
477 .field("index", &self.index)
478 .field("shared_djbz", &dbz_len)
479 .finish_non_exhaustive()
480 }
481}
482
483impl DjVuPage {
484 pub fn width(&self) -> u16 {
486 self.info.width
487 }
488
489 pub fn height(&self) -> u16 {
491 self.info.height
492 }
493
494 pub fn dpi(&self) -> u16 {
496 self.info.dpi
497 }
498
499 pub fn gamma(&self) -> f32 {
501 self.info.gamma
502 }
503
504 pub fn rotation(&self) -> crate::info::Rotation {
506 self.info.rotation
507 }
508
509 pub fn index(&self) -> usize {
511 self.index
512 }
513
514 pub fn resource_limits(&self) -> Option<crate::resource_limits::ResourceLimits> {
516 self.resource_limits
517 }
518
519 pub fn dimensions(&self) -> (u16, u16) {
521 (self.info.width, self.info.height)
522 }
523
524 pub fn thumbnail(&self) -> Result<Option<Pixmap>, DocError> {
530 let th44_chunks: Vec<&[u8]> = self
531 .chunk_slice()
532 .iter()
533 .filter(|c| &c.id == b"TH44")
534 .map(|c| c.data.as_slice())
535 .collect();
536
537 if th44_chunks.is_empty() {
538 return Ok(None);
539 }
540
541 let mut img = Iw44Image::new();
542 for chunk_data in &th44_chunks {
543 img.decode_chunk(chunk_data)?;
544 }
545 let pixmap = img.to_rgb()?;
546 Ok(Some(pixmap))
547 }
548
549 #[cfg(feature = "std")]
566 pub fn evict_render_cache(&self) {
567 if let Some(layers) = self.render_layers.get() {
568 layers.evict_shared();
569 }
570 }
571
572 #[cfg(feature = "std")]
589 pub fn downgrade_render_cache(&self) {
590 if let Some(layers) = self.render_layers.get() {
591 layers.downgrade();
592 }
593 }
594
595 #[cfg(feature = "std")]
608 fn chunk_slice(&self) -> &[RawChunk] {
609 self.chunks.get()
610 }
611 #[cfg(not(feature = "std"))]
612 fn chunk_slice(&self) -> &[RawChunk] {
613 &self.chunks
614 }
615
616 pub fn raw_chunk(&self, id: &[u8; 4]) -> Option<&[u8]> {
617 self.chunk_slice()
618 .iter()
619 .find(|c| &c.id == id)
620 .map(|c| c.data.as_slice())
621 }
622
623 pub fn all_chunks(&self, id: &[u8; 4]) -> Vec<&[u8]> {
634 self.chunk_slice()
635 .iter()
636 .filter(|c| &c.id == id)
637 .map(|c| c.data.as_slice())
638 .collect()
639 }
640
641 pub fn chunk_ids(&self) -> Vec<[u8; 4]> {
645 self.chunk_slice().iter().map(|c| c.id).collect()
646 }
647
648 #[doc(hidden)]
650 pub fn find_chunk(&self, id: &[u8; 4]) -> Option<&[u8]> {
651 self.raw_chunk(id)
652 }
653
654 #[doc(hidden)]
656 pub fn find_chunks(&self, id: &[u8; 4]) -> Vec<&[u8]> {
657 self.all_chunks(id)
658 }
659
660 pub fn chunk_payload(
668 &self,
669 id_z: &[u8; 4],
670 id_a: &[u8; 4],
671 ) -> Result<Option<Vec<u8>>, DocError> {
672 Ok(decode_paired_payload(
673 self.raw_chunk(id_z),
674 self.raw_chunk(id_a),
675 )?)
676 }
677
678 pub fn bg44_chunks(&self) -> Vec<&[u8]> {
684 let bg44 = self.find_chunks(b"BG44");
685 if !bg44.is_empty() {
686 return bg44;
687 }
688 let bm44 = self.find_chunks(b"BM44");
689 if !bm44.is_empty() {
690 return bm44;
691 }
692 self.find_chunks(b"PM44")
693 }
694
695 #[cfg(feature = "std")]
701 pub(crate) fn render_layers(&self) -> &crate::djvu_render::PageLayers {
702 let layers = self.render_layers.get_or_init(|| {
703 let layers = Arc::new(crate::djvu_render::PageLayers::new());
708 crate::render_cache::register(&layers);
709 layers
710 });
711 layers.bump_access();
715 layers
716 }
717
718 #[cfg(feature = "std")]
721 pub fn render_cache_bytes(&self) -> usize {
722 self.render_layers.get().map_or(0, |l| l.cached_bytes())
723 }
724
725 #[cfg(feature = "std")]
729 pub(crate) fn render_cache_access_tick(&self) -> u64 {
730 self.render_layers.get().map_or(0, |l| l.access_tick())
731 }
732
733 #[cfg(feature = "std")]
746 pub fn decoded_bg44(&self) -> Option<Arc<Iw44Image>> {
747 self.render_layers().bg44(self)
748 }
749
750 #[cfg(not(feature = "std"))]
751 pub fn decoded_bg44(&self) -> Option<Arc<Iw44Image>> {
752 None
753 }
754
755 #[cfg(feature = "std")]
763 pub fn decoded_bg44_partial(&self) -> Option<Arc<Iw44Image>> {
764 self.render_layers().bg44_partial(self)
765 }
766
767 #[cfg(not(feature = "std"))]
768 pub fn decoded_bg44_partial(&self) -> Option<Arc<Iw44Image>> {
769 None
770 }
771
772 #[cfg(feature = "std")]
775 pub(crate) fn cached_bg_rgb_subhi(&self, subsample: u32) -> Option<Arc<Pixmap>> {
776 self.render_layers.get()?.bg_rgb_subhi(subsample)
777 }
778
779 #[cfg(feature = "std")]
782 pub(crate) fn store_bg_rgb_subhi(&self, subsample: u32, px: Arc<Pixmap>) {
783 self.render_layers().store_bg_rgb_subhi(subsample, px);
784 }
785
786 #[cfg(feature = "std")]
790 pub(crate) fn cached_bg44_partial(&self) -> Option<Arc<Iw44Image>> {
791 self.render_layers.get()?.bg44_partial_cached()
792 }
793
794 #[cfg(feature = "std")]
800 pub(crate) fn decoded_shared_dict(&self) -> Option<&Jb2Dict> {
801 self.shared_djbz.as_ref()?.get()
804 }
805
806 #[cfg(not(feature = "std"))]
807 pub(crate) fn decoded_shared_dict(&self) -> Option<&Jb2Dict> {
808 None
809 }
810
811 pub fn fg44_chunks(&self) -> Vec<&[u8]> {
813 self.find_chunks(b"FG44")
814 }
815
816 pub fn text_layer(&self) -> Result<Option<TextLayer>, DocError> {
820 Ok(self.text_layer_shared()?.map(|arc| (*arc).clone()))
821 }
822
823 #[cfg(feature = "std")]
829 pub fn text_layer_shared(&self) -> Result<Option<std::sync::Arc<TextLayer>>, DocError> {
830 self.render_layers().text_layer_cached(|| {
831 let page_height = self.info.height as u32;
832 match self.chunk_payload(b"TXTz", b"TXTa")? {
833 Some(bytes) => Ok(Some(std::sync::Arc::new(crate::text::parse_text_layer(
834 &bytes,
835 page_height,
836 )?))),
837 None => Ok(None),
838 }
839 })
840 }
841
842 #[cfg(not(feature = "std"))]
843 pub fn text_layer_shared(&self) -> Result<Option<alloc::sync::Arc<TextLayer>>, DocError> {
844 let page_height = self.info.height as u32;
845 match self.chunk_payload(b"TXTz", b"TXTa")? {
846 Some(bytes) => Ok(Some(alloc::sync::Arc::new(crate::text::parse_text_layer(
847 &bytes,
848 page_height,
849 )?))),
850 None => Ok(None),
851 }
852 }
853
854 pub fn text_layer_at_size(
865 &self,
866 render_w: u32,
867 render_h: u32,
868 ) -> Result<Option<TextLayer>, DocError> {
869 let page_w = self.info.width as u32;
870 let page_h = self.info.height as u32;
871 let rotation = self.info.rotation;
872 Ok(self
873 .text_layer()?
874 .map(|tl| tl.transform(page_w, page_h, rotation, render_w, render_h)))
875 }
876
877 pub fn text(&self) -> Result<Option<String>, DocError> {
881 Ok(self.text_layer()?.map(|tl| tl.text))
882 }
883
884 pub fn annotations(&self) -> Result<Option<(Annotation, Vec<MapArea>)>, DocError> {
888 Ok(self.annotations_shared()?.map(|arc| (*arc).clone()))
889 }
890
891 #[cfg(feature = "std")]
894 pub fn annotations_shared(
895 &self,
896 ) -> Result<Option<crate::djvu_render::SharedAnnotations>, DocError> {
897 self.render_layers()
898 .annotations_cached(|| match self.chunk_payload(b"ANTz", b"ANTa")? {
899 Some(bytes) => Ok(Some(std::sync::Arc::new(
900 crate::annotation::parse_annotations(&bytes)?,
901 ))),
902 None => Ok(None),
903 })
904 }
905
906 #[cfg(not(feature = "std"))]
907 pub fn annotations_shared(
908 &self,
909 ) -> Result<Option<alloc::sync::Arc<(Annotation, Vec<MapArea>)>>, DocError> {
910 match self.chunk_payload(b"ANTz", b"ANTa")? {
911 Some(bytes) => Ok(Some(alloc::sync::Arc::new(
912 crate::annotation::parse_annotations(&bytes)?,
913 ))),
914 None => Ok(None),
915 }
916 }
917
918 pub fn hyperlinks(&self) -> Result<Vec<MapArea>, DocError> {
920 match self.annotations()? {
921 None => Ok(Vec::new()),
922 Some((_, mapareas)) => Ok(mapareas.into_iter().filter(|m| !m.url.is_empty()).collect()),
923 }
924 }
925
926 pub fn extract_mask(&self) -> Result<Option<crate::bitmap::Bitmap>, DocError> {
939 if let Some(sjbz) = self.find_chunk(b"Sjbz") {
940 let inline_dict;
944 let dict_ref = if let Some(djbz) = self.find_chunk(b"Djbz") {
945 inline_dict = crate::jb2::decode_dict(djbz, None)?;
946 Some(&inline_dict)
947 } else {
948 self.decoded_shared_dict()
949 };
950 let bm = crate::jb2::decode(sjbz, dict_ref)?;
951 return Ok(Some(bm));
952 }
953 if let Some(smmr) = self.find_chunk(b"Smmr") {
954 let bm = crate::smmr::decode_smmr(smmr).map_err(|e| DocError::Smmr(e.to_string()))?;
955 return Ok(Some(bm));
956 }
957 Ok(None)
958 }
959
960 pub fn extract_mask_indexed(
966 &self,
967 ) -> Result<Option<(crate::bitmap::Bitmap, Vec<i32>)>, DocError> {
968 if let Some(sjbz) = self.find_chunk(b"Sjbz") {
969 let inline_dict;
970 let dict_ref = if let Some(djbz) = self.find_chunk(b"Djbz") {
971 inline_dict = crate::jb2::decode_dict(djbz, None)?;
972 Some(&inline_dict)
973 } else {
974 self.decoded_shared_dict()
975 };
976 let (bm, blit_map) = crate::jb2::decode_indexed(sjbz, dict_ref)?;
977 return Ok(Some((bm, blit_map)));
978 }
979 if let Some(smmr) = self.find_chunk(b"Smmr") {
980 let bm = crate::smmr::decode_smmr(smmr).map_err(|e| DocError::Smmr(e.to_string()))?;
981 let len = (bm.width * bm.height) as usize;
982 return Ok(Some((bm, vec![0i32; len])));
983 }
984 Ok(None)
985 }
986
987 #[cfg(feature = "std")]
1005 pub(crate) fn extract_mask_sub4(&self) -> Result<Option<crate::bitmap::Bitmap>, DocError> {
1006 if let Some(sjbz) = self.find_chunk(b"Sjbz") {
1007 let inline_dict;
1008 let dict_ref = if let Some(djbz) = self.find_chunk(b"Djbz") {
1009 inline_dict = crate::jb2::decode_dict(djbz, None)?;
1010 Some(&inline_dict)
1011 } else {
1012 self.decoded_shared_dict()
1013 };
1014 let bm = crate::jb2::decode_downsampled(sjbz, dict_ref, 2)?;
1015 return Ok(Some(bm));
1016 }
1017 if let Some(smmr) = self.find_chunk(b"Smmr") {
1018 let bm = crate::smmr::decode_smmr(smmr).map_err(|e| DocError::Smmr(e.to_string()))?;
1022 return Ok(Some(crate::djvu_render::downsample_mask_4x(&bm)));
1023 }
1024 Ok(None)
1025 }
1026
1027 pub fn extract_foreground(&self) -> Result<Option<Pixmap>, DocError> {
1035 let chunks = self.fg44_chunks();
1036 if chunks.is_empty() {
1037 return Ok(None);
1038 }
1039
1040 let mut img = Iw44Image::new();
1041 for chunk_data in &chunks {
1042 img.decode_chunk(chunk_data)?;
1043 }
1044 let pixmap = img.to_rgb()?;
1045 Ok(Some(pixmap))
1046 }
1047
1048 #[cfg(feature = "std")]
1057 pub fn decoded_mask(&self) -> Option<Arc<crate::bitmap::Bitmap>> {
1058 self.render_layers().mask(self)
1059 }
1060
1061 #[cfg(not(feature = "std"))]
1062 pub fn decoded_mask(&self) -> Option<Arc<crate::bitmap::Bitmap>> {
1063 None
1064 }
1065
1066 #[cfg(feature = "std")]
1071 pub fn decoded_fg44(&self) -> Option<Arc<Pixmap>> {
1072 self.render_layers().fg44(self)
1073 }
1074
1075 #[cfg(not(feature = "std"))]
1076 pub fn decoded_fg44(&self) -> Option<Arc<Pixmap>> {
1077 None
1078 }
1079
1080 #[cfg(feature = "std")]
1089 pub(crate) fn decoded_bg_rgb_s1(&self) -> Option<Arc<Pixmap>> {
1090 self.render_layers().bg_rgb_s1(self)
1091 }
1092
1093 #[cfg(not(feature = "std"))]
1094 pub(crate) fn decoded_bg_rgb_s1(&self) -> Option<Arc<Pixmap>> {
1095 None
1096 }
1097
1098 #[cfg(feature = "std")]
1107 pub(crate) fn decoded_bg_rgb_s2(&self) -> Option<Arc<Pixmap>> {
1108 self.render_layers().bg_rgb_s2(self)
1109 }
1110
1111 #[cfg(not(feature = "std"))]
1112 pub(crate) fn decoded_bg_rgb_s2(&self) -> Option<Arc<Pixmap>> {
1113 None
1114 }
1115
1116 #[cfg(feature = "std")]
1126 pub(crate) fn decoded_bg_rgb_s4(&self) -> Option<Arc<Pixmap>> {
1127 self.render_layers().bg_rgb_s4(self)
1128 }
1129
1130 #[cfg(not(feature = "std"))]
1131 pub(crate) fn decoded_bg_rgb_s4(&self) -> Option<Arc<Pixmap>> {
1132 None
1133 }
1134
1135 #[cfg(feature = "std")]
1142 pub(crate) fn decoded_mask_indexed(&self) -> Option<Arc<crate::djvu_render::IndexedMask>> {
1143 self.render_layers().mask_indexed(self)
1144 }
1145
1146 #[cfg(not(feature = "std"))]
1147 pub(crate) fn decoded_mask_indexed(&self) -> Option<Arc<crate::djvu_render::IndexedMask>> {
1148 None
1149 }
1150
1151 pub fn extract_background(&self) -> Result<Option<Pixmap>, DocError> {
1159 let chunks = self.bg44_chunks();
1160 if chunks.is_empty() {
1161 return Ok(None);
1162 }
1163
1164 let mut img = Iw44Image::new();
1165 for chunk_data in &chunks {
1166 img.decode_chunk(chunk_data)?;
1167 }
1168 let pixmap = img.to_rgb()?;
1169 Ok(Some(pixmap))
1170 }
1171
1172 pub fn render_into(
1183 &self,
1184 opts: &crate::djvu_render::RenderOptions,
1185 buf: &mut [u8],
1186 ) -> Result<(), crate::djvu_render::RenderError> {
1187 crate::djvu_render::render_into(self, opts, buf)
1188 }
1189}
1190
1191#[cfg(feature = "std")]
1198#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1199pub struct CacheBudgetOptions {
1200 pub downgrade_before_drop: bool,
1211}
1212
1213#[derive(Debug)]
1218pub struct DjVuDocument {
1219 pages: Vec<DjVuPage>,
1221 bookmarks: Vec<DjVuBookmark>,
1223 global_chunks: Vec<RawChunk>,
1226 page_byte_ranges: Vec<core::ops::Range<u64>>,
1235 resource_limits: Option<crate::resource_limits::ResourceLimits>,
1237}
1238
1239#[cfg(feature = "std")]
1240fn attach_resource_limits(
1241 mut document: DjVuDocument,
1242 limits: Option<crate::resource_limits::ResourceLimits>,
1243) -> DjVuDocument {
1244 document.resource_limits = limits;
1245 if let Some(limits) = limits {
1246 for page in &mut document.pages {
1247 page.resource_limits = Some(limits);
1248 }
1249 }
1250 document
1251}
1252
1253#[cfg(feature = "std")]
1254fn check_parse_limits(
1255 data: &[u8],
1256 limits: Option<crate::resource_limits::ResourceLimits>,
1257) -> Result<(), DocError> {
1258 if let Some(limits) = limits.filter(|limits| !limits.is_empty()) {
1259 let _ = crate::validate::check_document_limits(data, &limits, "document.parse")?;
1260 }
1261 Ok(())
1262}
1263
1264impl DjVuDocument {
1265 pub fn parse(data: &[u8]) -> Result<Self, DocError> {
1275 #[cfg(feature = "std")]
1276 {
1277 Self::parse_with_options(data, &crate::resource_limits::ParseOptions::default())
1278 }
1279 #[cfg(not(feature = "std"))]
1280 {
1281 Self::parse_with_resolver(data, None::<fn(&str) -> Result<Vec<u8>, DocError>>)
1282 }
1283 }
1284
1285 #[cfg(feature = "std")]
1292 pub fn parse_with_options(
1293 data: &[u8],
1294 opts: &crate::resource_limits::ParseOptions,
1295 ) -> Result<Self, DocError> {
1296 Self::parse_with_resolver_and_options(
1297 data,
1298 None::<fn(&str) -> Result<Vec<u8>, DocError>>,
1299 opts,
1300 )
1301 }
1302
1303 #[cfg(feature = "std")]
1305 pub fn parse_with_resolver_and_options<R>(
1306 data: &[u8],
1307 resolver: Option<R>,
1308 opts: &crate::resource_limits::ParseOptions,
1309 ) -> Result<Self, DocError>
1310 where
1311 R: Fn(&str) -> Result<Vec<u8>, DocError>,
1312 {
1313 check_parse_limits(data, opts.limits)?;
1314 let document = Self::parse_with_resolver(data, resolver)?;
1315 Ok(attach_resource_limits(document, opts.limits))
1316 }
1317
1318 pub fn resource_limits(&self) -> Option<crate::resource_limits::ResourceLimits> {
1320 self.resource_limits
1321 }
1322
1323 #[cfg(feature = "std")]
1338 pub(crate) fn parse_backed_with_options(
1339 backing: Backing,
1340 opts: &crate::resource_limits::ParseOptions,
1341 ) -> Result<Self, DocError> {
1342 check_parse_limits(backing_bytes(&backing), opts.limits)?;
1343 let data = backing_bytes(&backing);
1344 let form = parse_form(data)?;
1345 if &form.form_type != b"DJVM" {
1346 return Self::parse_with_resolver_and_options(
1347 data,
1348 None::<fn(&str) -> Result<Vec<u8>, DocError>>,
1349 opts,
1350 );
1351 }
1352 let Some(dirm_chunk) = form.chunks.iter().find(|c| &c.id == b"DIRM") else {
1353 return Self::parse_with_resolver_and_options(
1354 data,
1355 None::<fn(&str) -> Result<Vec<u8>, DocError>>,
1356 opts,
1357 );
1358 };
1359 let payload = DirmPayload::decode(dirm_chunk.data).map_err(DocError::Malformed)?;
1360 if !payload.is_bundled() {
1361 return Self::parse_with_resolver_and_options(
1364 data,
1365 None::<fn(&str) -> Result<Vec<u8>, DocError>>,
1366 opts,
1367 );
1368 }
1369
1370 let entries = payload.components();
1371 let comp_offsets = &payload.offsets;
1372 let bookmarks = parse_navm_bookmarks(&form.chunks)?;
1373 let global_chunks: Vec<RawChunk> = form
1374 .chunks
1375 .iter()
1376 .filter(|c| &c.id != b"FORM")
1377 .map(|c| RawChunk {
1378 id: c.id,
1379 data: c.data.to_vec(),
1380 })
1381 .collect();
1382
1383 let sub_forms: Vec<&IffChunk<'_>> =
1384 form.chunks.iter().filter(|c| &c.id == b"FORM").collect();
1385
1386 use std::collections::BTreeMap;
1387 let djvi_djbz: BTreeMap<String, Arc<SharedDict>> = entries
1388 .iter()
1389 .enumerate()
1390 .filter(|(_, e)| e.kind == DirmComponentKind::Shared)
1391 .filter_map(|(comp_idx, entry)| {
1392 let sf = sub_forms.get(comp_idx)?;
1393 let chunks = parse_sub_form(sf.data).ok()?;
1394 let djbz = chunks.iter().find(|c| &c.id == b"Djbz")?;
1395 Some((
1396 entry.id.clone(),
1397 Arc::new(SharedDict::new(djbz.data.to_vec())),
1398 ))
1399 })
1400 .collect();
1401
1402 let base = data.as_ptr() as usize;
1403 let mut pages = Vec::new();
1404 let mut page_byte_ranges = Vec::new();
1405 let mut page_idx = 0usize;
1406 for (comp_idx, entry) in entries.iter().enumerate() {
1407 if entry.kind != DirmComponentKind::Page {
1408 continue;
1409 }
1410 let sub_form = sub_forms.get(comp_idx).ok_or(DocError::Malformed(
1411 "DIRM entry count exceeds FORM children",
1412 ))?;
1413 let sub_chunks = parse_sub_form(sub_form.data)?;
1414 let shared_djbz = sub_chunks
1415 .iter()
1416 .find(|c| &c.id == b"INCL")
1417 .and_then(|incl| core::str::from_utf8(incl.data.trim_ascii_end()).ok())
1418 .and_then(|name| djvi_djbz.get(name))
1419 .cloned();
1420
1421 let off = sub_form.data.as_ptr() as usize - base;
1425 let range = off..off + sub_form.data.len();
1426 let page = parse_page_lazy(&sub_chunks, page_idx, shared_djbz, backing.clone(), range)?;
1427 pages.push(page);
1428
1429 if let Some(off2) = comp_offsets.get(comp_idx) {
1430 let start = *off2 as usize;
1431 if let Some(size_bytes) = data.get(start + 4..start + 8) {
1432 let size_be = [size_bytes[0], size_bytes[1], size_bytes[2], size_bytes[3]];
1433 page_byte_ranges.push(crate::dirm::form_byte_range(*off2, size_be));
1434 }
1435 }
1436 page_idx += 1;
1437 }
1438 if page_byte_ranges.len() != pages.len() {
1439 page_byte_ranges.clear();
1440 }
1441
1442 Ok(attach_resource_limits(
1443 DjVuDocument {
1444 pages,
1445 bookmarks,
1446 global_chunks,
1447 page_byte_ranges,
1448 resource_limits: None,
1449 },
1450 opts.limits,
1451 ))
1452 }
1453
1454 pub fn parse_with_component_resolver<R>(data: &[u8], resolver: &R) -> Result<Self, DocError>
1466 where
1467 R: ComponentResolver + ?Sized,
1468 {
1469 let form = parse_form(data)?;
1470 if form.form_type != *b"DJVM" {
1471 return Self::parse(data);
1474 }
1475
1476 let dirm_chunk = form
1477 .chunks
1478 .iter()
1479 .find(|c| &c.id == b"DIRM")
1480 .ok_or(DocError::MissingChunk("DIRM"))?;
1481 let payload = DirmPayload::decode(dirm_chunk.data).map_err(DocError::Malformed)?;
1482 if payload.is_bundled() {
1483 return Self::parse(data);
1486 }
1487
1488 let entries = payload.components();
1489 let bookmarks = parse_navm_bookmarks(&form.chunks)?;
1490 let global_chunks: Vec<RawChunk> = form
1491 .chunks
1492 .iter()
1493 .filter(|c| &c.id != b"FORM")
1494 .map(|c| RawChunk {
1495 id: c.id,
1496 data: c.data.to_vec(),
1497 })
1498 .collect();
1499
1500 #[cfg(not(feature = "std"))]
1501 use alloc::collections::BTreeMap;
1502 #[cfg(feature = "std")]
1503 use std::collections::BTreeMap;
1504
1505 #[cfg(feature = "std")]
1506 let mut shared_djbz: BTreeMap<String, Arc<SharedDict>> = BTreeMap::new();
1507 #[cfg(not(feature = "std"))]
1508 let mut shared_djbz: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1509 let mut page_components: Vec<(ComponentId, Vec<u8>)> = Vec::new();
1510
1511 for entry in &entries {
1512 let component = component_id_from_dirm(entry);
1513 let component_kind = component.kind;
1514 let resolved = resolver
1515 .resolve(&component)
1516 .map_err(DocError::ComponentResolve)?;
1517 let resolved_form = parse_form(&resolved)?;
1518 let expected = expected_component_form(component_kind);
1519 if resolved_form.form_type != expected {
1520 return Err(DocError::ComponentKindMismatch {
1521 component,
1522 found: resolved_form.form_type,
1523 expected: component_kind,
1524 });
1525 }
1526
1527 match component_kind {
1528 ComponentKind::Page => page_components.push((component, resolved)),
1529 ComponentKind::Shared => {
1530 if let Some(djbz) = resolved_form.chunks.iter().find(|c| &c.id == b"Djbz") {
1534 #[cfg(feature = "std")]
1535 shared_djbz.insert(
1536 component.name,
1537 Arc::new(SharedDict::new(djbz.data.to_vec())),
1538 );
1539 #[cfg(not(feature = "std"))]
1540 shared_djbz.insert(component.name, djbz.data.to_vec());
1541 }
1542 }
1543 ComponentKind::Thumbnail => {}
1544 }
1545 }
1546
1547 let mut pages = Vec::with_capacity(page_components.len());
1548 for (page_idx, (_component, resolved)) in page_components.iter().enumerate() {
1549 let page_form = parse_form(resolved)?;
1550 let shared_for_page = page_form
1551 .chunks
1552 .iter()
1553 .filter(|c| &c.id == b"INCL")
1554 .filter_map(|incl| core::str::from_utf8(incl.data.trim_ascii_end()).ok())
1555 .find_map(|name| shared_djbz.get(name))
1556 .cloned();
1557 pages.push(parse_page_from_chunks(
1558 &page_form.chunks,
1559 page_idx,
1560 shared_for_page,
1561 )?);
1562 }
1563
1564 Ok(DjVuDocument {
1565 pages,
1566 bookmarks,
1567 global_chunks,
1568 page_byte_ranges: Vec::new(),
1570 resource_limits: None,
1571 })
1572 }
1573
1574 pub fn parse_with_resolver<R>(data: &[u8], resolver: Option<R>) -> Result<Self, DocError>
1579 where
1580 R: Fn(&str) -> Result<Vec<u8>, DocError>,
1581 {
1582 let form = parse_form(data)?;
1583
1584 match &form.form_type {
1585 b"DJVU" => {
1586 let global_chunks: Vec<RawChunk> = form
1588 .chunks
1589 .iter()
1590 .map(|c| RawChunk {
1591 id: c.id,
1592 data: c.data.to_vec(),
1593 })
1594 .collect();
1595 let page = parse_page_from_chunks(&form.chunks, 0, None)?;
1596 #[allow(clippy::single_range_in_vec_init)]
1598 let page_byte_ranges = vec![0u64..(data.len() as u64)];
1599 Ok(DjVuDocument {
1600 pages: vec![page],
1601 bookmarks: vec![],
1602 global_chunks,
1603 page_byte_ranges,
1604 resource_limits: None,
1605 })
1606 }
1607 b"BM44" | b"PM44" => {
1608 let page = parse_legacy_iw44_page(&form.form_type, &form.chunks, 0)?;
1609 #[allow(clippy::single_range_in_vec_init)]
1610 let page_byte_ranges = vec![0u64..(data.len() as u64)];
1611 Ok(DjVuDocument {
1612 pages: vec![page],
1613 bookmarks: vec![],
1614 global_chunks: Vec::new(),
1615 page_byte_ranges,
1616 resource_limits: None,
1617 })
1618 }
1619 b"DJVM" => {
1620 let dirm_chunk = form
1622 .chunks
1623 .iter()
1624 .find(|c| &c.id == b"DIRM")
1625 .ok_or(DocError::MissingChunk("DIRM"))?;
1626
1627 let payload = DirmPayload::decode(dirm_chunk.data).map_err(DocError::Malformed)?;
1628 let entries = payload.components();
1629 let is_bundled = payload.is_bundled();
1630 let comp_offsets = payload.offsets;
1631
1632 let bookmarks = parse_navm_bookmarks(&form.chunks)?;
1634
1635 let global_chunks: Vec<RawChunk> = form
1637 .chunks
1638 .iter()
1639 .filter(|c| &c.id != b"FORM")
1640 .map(|c| RawChunk {
1641 id: c.id,
1642 data: c.data.to_vec(),
1643 })
1644 .collect();
1645
1646 if is_bundled {
1647 let sub_forms: Vec<&IffChunk<'_>> =
1649 form.chunks.iter().filter(|c| &c.id == b"FORM").collect();
1650
1651 #[cfg(not(feature = "std"))]
1656 use alloc::collections::BTreeMap;
1657 #[cfg(feature = "std")]
1658 use std::collections::BTreeMap;
1659 #[cfg(feature = "std")]
1662 let djvi_djbz: BTreeMap<String, Arc<SharedDict>> = entries
1663 .iter()
1664 .enumerate()
1665 .filter(|(_, e)| e.kind == DirmComponentKind::Shared)
1666 .filter_map(|(comp_idx, entry)| {
1667 let sf = sub_forms.get(comp_idx)?;
1668 let chunks = parse_sub_form(sf.data).ok()?;
1669 let djbz = chunks.iter().find(|c| &c.id == b"Djbz")?;
1670 Some((
1671 entry.id.clone(),
1672 Arc::new(SharedDict::new(djbz.data.to_vec())),
1673 ))
1674 })
1675 .collect();
1676 #[cfg(not(feature = "std"))]
1677 let djvi_djbz: BTreeMap<String, Vec<u8>> = entries
1678 .iter()
1679 .enumerate()
1680 .filter(|(_, e)| e.kind == DirmComponentKind::Shared)
1681 .filter_map(|(comp_idx, entry)| {
1682 let sf = sub_forms.get(comp_idx)?;
1683 let chunks = parse_sub_form(sf.data).ok()?;
1684 let djbz = chunks.iter().find(|c| &c.id == b"Djbz")?;
1685 Some((entry.id.clone(), djbz.data.to_vec()))
1686 })
1687 .collect();
1688
1689 let mut pages = Vec::new();
1690 let mut page_byte_ranges = Vec::new();
1691 let mut page_idx = 0usize;
1692 for (comp_idx, entry) in entries.iter().enumerate() {
1693 if entry.kind != DirmComponentKind::Page {
1694 continue;
1695 }
1696 let sub_form = sub_forms.get(comp_idx).ok_or(DocError::Malformed(
1697 "DIRM entry count exceeds FORM children",
1698 ))?;
1699 let sub_chunks = parse_sub_form(sub_form.data)?;
1700
1701 let shared_djbz = sub_chunks
1708 .iter()
1709 .filter(|c| &c.id == b"INCL")
1710 .filter_map(|incl| {
1711 core::str::from_utf8(incl.data.trim_ascii_end()).ok()
1712 })
1713 .find_map(|name| djvi_djbz.get(name))
1714 .cloned();
1715
1716 let page = parse_page_from_chunks(&sub_chunks, page_idx, shared_djbz)?;
1717 pages.push(page);
1718
1719 if let Some(off) = comp_offsets.get(comp_idx) {
1724 let start = *off as usize;
1725 if let Some(size_bytes) = start
1729 .checked_add(8)
1730 .and_then(|end| data.get(start + 4..end))
1731 {
1732 let size_be =
1733 [size_bytes[0], size_bytes[1], size_bytes[2], size_bytes[3]];
1734 page_byte_ranges.push(crate::dirm::form_byte_range(*off, size_be));
1735 }
1736 }
1737 page_idx += 1;
1738 }
1739
1740 if page_byte_ranges.len() != pages.len() {
1743 page_byte_ranges.clear();
1744 }
1745
1746 Ok(DjVuDocument {
1747 pages,
1748 bookmarks,
1749 global_chunks,
1750 page_byte_ranges,
1751 resource_limits: None,
1752 })
1753 } else {
1754 let resolver = resolver.ok_or(DocError::NoResolver)?;
1756
1757 let mut pages = Vec::new();
1758 let mut page_idx = 0usize;
1759 for entry in &entries {
1760 if entry.kind != DirmComponentKind::Page {
1761 continue;
1762 }
1763 let resolved_data = resolver(&entry.id)
1764 .map_err(|_| DocError::IndirectResolve(entry.id.clone()))?;
1765 let sub_form = parse_form(&resolved_data)?;
1766 let page = parse_page_from_chunks(&sub_form.chunks, page_idx, None)?;
1767 pages.push(page);
1768 page_idx += 1;
1769 }
1770
1771 Ok(DjVuDocument {
1772 pages,
1773 bookmarks,
1774 global_chunks,
1775 page_byte_ranges: Vec::new(),
1778 resource_limits: None,
1779 })
1780 }
1781 }
1782 other => Err(DocError::NotDjVu(*other)),
1783 }
1784 }
1785
1786 #[cfg(all(feature = "std", feature = "async"))]
1787 pub(crate) fn parse_single_page_with_shared(
1788 data: &[u8],
1789 index: usize,
1790 shared_djbz: Option<Arc<SharedDict>>,
1791 ) -> Result<DjVuPage, DocError> {
1792 let form = parse_form(data)?;
1793 if form.form_type != *b"DJVU" {
1794 return Err(DocError::NotDjVu(form.form_type));
1795 }
1796 parse_page_from_chunks(&form.chunks, index, shared_djbz)
1797 }
1798
1799 pub fn page_count(&self) -> usize {
1801 self.pages.len()
1802 }
1803
1804 pub fn page_byte_range(&self, index: usize) -> Option<core::ops::Range<u64>> {
1822 self.page_byte_ranges.get(index).cloned()
1823 }
1824
1825 #[cfg(all(feature = "std", feature = "parallel"))]
1845 pub fn prefetch_page(self: &Arc<Self>, index: usize) {
1846 if index >= self.page_count() {
1847 return;
1848 }
1849 let doc = Arc::clone(self);
1850 rayon::spawn(move || {
1851 let Ok(page) = doc.page(index) else {
1852 return;
1853 };
1854 let _ = page.decoded_mask();
1861 let _ = page.decoded_fg44();
1862 let _ = page.decoded_bg_rgb_s1();
1863 });
1864 }
1865
1866 pub fn page(&self, index: usize) -> Result<&DjVuPage, DocError> {
1872 self.pages.get(index).ok_or(DocError::PageOutOfRange {
1873 index,
1874 count: self.pages.len(),
1875 })
1876 }
1877
1878 #[cfg(feature = "std")]
1885 pub fn evict_render_caches(&mut self) {
1886 for p in &mut self.pages {
1887 p.evict_render_cache();
1888 }
1889 }
1890
1891 #[cfg(feature = "std")]
1895 pub fn retain_render_caches(&self, keep: &[usize]) {
1896 for (i, p) in self.pages.iter().enumerate() {
1897 if !keep.contains(&i) {
1898 p.evict_render_cache();
1899 }
1900 }
1901 }
1902
1903 #[cfg(feature = "std")]
1908 pub fn render_cache_bytes(&self) -> usize {
1909 self.pages.iter().map(|p| p.render_cache_bytes()).sum()
1910 }
1911
1912 #[cfg(feature = "std")]
1924 pub fn enforce_cache_budget(&self, max_bytes: usize, protect: &[usize]) -> usize {
1925 let mut total = self.render_cache_bytes();
1926 if total <= max_bytes {
1927 return 0;
1928 }
1929 let mut cands: Vec<(usize, u64, usize)> = self
1931 .pages
1932 .iter()
1933 .enumerate()
1934 .filter(|(i, p)| !protect.contains(i) && p.render_cache_bytes() > 0)
1935 .map(|(i, p)| (i, p.render_cache_access_tick(), p.render_cache_bytes()))
1936 .collect();
1937 cands.sort_by_key(|&(_, tick, _)| tick);
1938
1939 let mut freed = 0usize;
1940 for (i, _, bytes) in cands {
1941 if total <= max_bytes {
1942 break;
1943 }
1944 self.pages[i].evict_render_cache();
1945 freed += bytes;
1946 total = total.saturating_sub(bytes);
1947 }
1948 freed
1949 }
1950
1951 #[cfg(feature = "std")]
1954 pub fn downgrade_render_caches(&self) {
1955 for p in &self.pages {
1956 p.downgrade_render_cache();
1957 }
1958 }
1959
1960 #[cfg(feature = "std")]
1965 pub fn enforce_cache_budget_with(
1966 &self,
1967 max_bytes: usize,
1968 protect: &[usize],
1969 opts: CacheBudgetOptions,
1970 ) -> usize {
1971 if !opts.downgrade_before_drop {
1972 return self.enforce_cache_budget(max_bytes, protect);
1973 }
1974 let mut total = self.render_cache_bytes();
1975 if total <= max_bytes {
1976 return 0;
1977 }
1978 let starting_total = total;
1979
1980 let mut cands: Vec<(usize, u64, usize)> = self
1983 .pages
1984 .iter()
1985 .enumerate()
1986 .filter(|(i, p)| !protect.contains(i) && p.render_cache_bytes() > 0)
1987 .map(|(i, p)| (i, p.render_cache_access_tick(), p.render_cache_bytes()))
1988 .collect();
1989 cands.sort_by_key(|&(_, tick, _)| tick);
1990
1991 for &(i, _, before) in &cands {
1992 if total <= max_bytes {
1993 break;
1994 }
1995 self.pages[i].downgrade_render_cache();
1996 let after = self.pages[i].render_cache_bytes();
1997 total = total.saturating_sub(before.saturating_sub(after));
1998 }
1999
2000 if total > max_bytes {
2004 let mut cands2: Vec<(usize, u64, usize)> = self
2005 .pages
2006 .iter()
2007 .enumerate()
2008 .filter(|(i, p)| !protect.contains(i) && p.render_cache_bytes() > 0)
2009 .map(|(i, p)| (i, p.render_cache_access_tick(), p.render_cache_bytes()))
2010 .collect();
2011 cands2.sort_by_key(|&(_, tick, _)| tick);
2012
2013 for (i, _, bytes) in cands2 {
2014 if total <= max_bytes {
2015 break;
2016 }
2017 self.pages[i].evict_render_cache();
2018 total = total.saturating_sub(bytes);
2019 }
2020 }
2021
2022 starting_total.saturating_sub(total)
2023 }
2024
2025 pub fn bookmarks(&self) -> &[DjVuBookmark] {
2027 &self.bookmarks
2028 }
2029
2030 pub fn metadata(&self) -> Result<Option<DjVuMetadata>, DocError> {
2035 match self.chunk_payload(b"METz", b"METa")? {
2036 Some(bytes) => Ok(Some(crate::metadata::parse_metadata(&bytes)?)),
2037 None => Ok(None),
2038 }
2039 }
2040
2041 pub fn component_directory(&self) -> Result<Vec<ComponentDirectoryEntry>, DocError> {
2047 let Some(data) = self.raw_chunk(b"DIRM") else {
2048 return Ok(Vec::new());
2049 };
2050 let payload = DirmPayload::decode(data).map_err(DocError::Malformed)?;
2051 Ok(payload
2052 .components()
2053 .into_iter()
2054 .map(|component| ComponentDirectoryEntry {
2055 kind: match component.kind {
2056 DirmComponentKind::Page => 'P',
2057 DirmComponentKind::Thumbnail => 'T',
2058 DirmComponentKind::Shared => 'I',
2059 },
2060 id: component.id,
2061 })
2062 .collect())
2063 }
2064
2065 pub fn raw_chunk(&self, id: &[u8; 4]) -> Option<&[u8]> {
2075 self.global_chunks
2076 .iter()
2077 .find(|c| &c.id == id)
2078 .map(|c| c.data.as_slice())
2079 }
2080
2081 pub fn all_chunks(&self, id: &[u8; 4]) -> Vec<&[u8]> {
2085 self.global_chunks
2086 .iter()
2087 .filter(|c| &c.id == id)
2088 .map(|c| c.data.as_slice())
2089 .collect()
2090 }
2091
2092 pub fn chunk_ids(&self) -> Vec<[u8; 4]> {
2097 self.global_chunks.iter().map(|c| c.id).collect()
2098 }
2099
2100 pub fn chunk_payload(
2107 &self,
2108 id_z: &[u8; 4],
2109 id_a: &[u8; 4],
2110 ) -> Result<Option<Vec<u8>>, DocError> {
2111 Ok(decode_paired_payload(
2112 self.raw_chunk(id_z),
2113 self.raw_chunk(id_a),
2114 )?)
2115 }
2116
2117 #[cfg(feature = "std")]
2129 pub fn parse_from_dir(
2130 data: &[u8],
2131 base_dir: impl AsRef<std::path::Path>,
2132 ) -> Result<Self, DocError> {
2133 Self::parse_from_dir_with_options(
2134 data,
2135 base_dir,
2136 &crate::resource_limits::ParseOptions::default(),
2137 )
2138 }
2139
2140 #[cfg(feature = "std")]
2142 pub fn parse_from_dir_with_options(
2143 data: &[u8],
2144 base_dir: impl AsRef<std::path::Path>,
2145 opts: &crate::resource_limits::ParseOptions,
2146 ) -> Result<Self, DocError> {
2147 let base = base_dir.as_ref().to_path_buf();
2148 let resolver = move |name: &str| -> Result<Vec<u8>, DocError> {
2149 let name = name.strip_prefix("file://").unwrap_or(name);
2151 let path = if std::path::Path::new(name).is_absolute() {
2152 std::path::PathBuf::from(name)
2153 } else {
2154 base.join(name)
2155 };
2156 std::fs::read(&path).map_err(|_| DocError::IndirectResolve(name.to_string()))
2157 };
2158 Self::parse_with_resolver_and_options(data, Some(resolver), opts)
2159 }
2160}
2161
2162#[cfg(feature = "mmap")]
2181pub struct MmapDocument {
2182 _backing: Backing,
2189 mmap: Arc<memmap2::Mmap>,
2194 doc: DjVuDocument,
2195}
2196
2197#[cfg(feature = "mmap")]
2198impl MmapDocument {
2199 pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, DocError> {
2212 let file = std::fs::File::open(path.as_ref())?;
2213
2214 #[allow(unsafe_code)]
2217 let mmap = unsafe { memmap2::Mmap::map(&file) }?;
2218
2219 let mmap = Arc::new(mmap);
2224 let backing: Backing = mmap.clone();
2225 let doc = DjVuDocument::parse_backed_with_options(
2226 backing.clone(),
2227 &crate::resource_limits::ParseOptions::default(),
2228 )?;
2229 Ok(MmapDocument {
2230 _backing: backing,
2231 mmap,
2232 doc,
2233 })
2234 }
2235
2236 pub fn open_indirect(path: impl AsRef<std::path::Path>) -> Result<Self, DocError> {
2247 let path = path.as_ref();
2248 let file = std::fs::File::open(path)?;
2249 #[allow(unsafe_code)]
2250 let mmap = unsafe { memmap2::Mmap::map(&file) }?;
2251
2252 let base_dir = path
2253 .parent()
2254 .map(|p| p.to_path_buf())
2255 .unwrap_or_else(|| std::path::PathBuf::from("."));
2256 let doc = DjVuDocument::parse_from_dir(&mmap, &base_dir)?;
2259 let mmap = Arc::new(mmap);
2260 let backing: Backing = mmap.clone();
2261 Ok(MmapDocument {
2262 _backing: backing,
2263 mmap,
2264 doc,
2265 })
2266 }
2267
2268 pub fn document(&self) -> &DjVuDocument {
2270 &self.doc
2271 }
2272
2273 pub fn page_count(&self) -> usize {
2275 self.doc.page_count()
2276 }
2277
2278 pub fn page(&self, index: usize) -> Result<&DjVuPage, DocError> {
2280 self.doc.page(index)
2281 }
2282
2283 #[cfg(unix)]
2324 pub fn advise_page_willneed(&self, index: usize) -> std::io::Result<()> {
2325 let Some(range) = self.doc.page_byte_range(index) else {
2326 return Ok(());
2327 };
2328 let start = (range.start as usize).min(self.mmap.len());
2329 let end = (range.end as usize).min(self.mmap.len());
2330 if end <= start {
2331 return Ok(());
2332 }
2333 self.mmap
2334 .advise_range(memmap2::Advice::WillNeed, start, end - start)
2335 }
2336
2337 pub fn into_document(self) -> DjVuDocument {
2348 self.doc
2349 }
2350}
2351
2352#[cfg(feature = "mmap")]
2353impl core::ops::Deref for MmapDocument {
2354 type Target = DjVuDocument;
2355 fn deref(&self) -> &DjVuDocument {
2356 &self.doc
2357 }
2358}
2359
2360fn component_id_from_dirm(component: &DirmComponent) -> ComponentId {
2363 let kind = match component.kind {
2364 DirmComponentKind::Page => ComponentKind::Page,
2365 DirmComponentKind::Shared => ComponentKind::Shared,
2366 DirmComponentKind::Thumbnail => ComponentKind::Thumbnail,
2367 };
2368 ComponentId::new(component.id.clone(), kind)
2369}
2370
2371fn expected_component_form(kind: ComponentKind) -> [u8; 4] {
2372 match kind {
2373 ComponentKind::Page => *b"DJVU",
2374 ComponentKind::Shared => *b"DJVI",
2375 ComponentKind::Thumbnail => *b"THUM",
2376 }
2377}
2378
2379#[cfg(feature = "std")]
2390fn parse_page_lazy(
2391 chunks: &[IffChunk<'_>],
2392 index: usize,
2393 shared_djbz: Option<Arc<SharedDict>>,
2394 backing: Backing,
2395 range: core::ops::Range<usize>,
2396) -> Result<DjVuPage, DocError> {
2397 let info_chunk = chunks
2398 .iter()
2399 .find(|c| &c.id == b"INFO")
2400 .ok_or(DocError::MissingChunk("INFO"))?;
2401 let info = PageInfo::parse(info_chunk.data)?;
2402 Ok(DjVuPage {
2403 info,
2404 chunks: ChunkStore::Lazy {
2405 backing,
2406 range,
2407 cache: std::sync::OnceLock::new(),
2408 },
2409 index,
2410 shared_djbz,
2411 render_layers: std::sync::OnceLock::new(),
2412 resource_limits: None,
2413 })
2414}
2415
2416#[cfg(feature = "std")]
2417fn parse_page_from_chunks(
2418 chunks: &[IffChunk<'_>],
2419 index: usize,
2420 shared_djbz: Option<Arc<SharedDict>>,
2421) -> Result<DjVuPage, DocError> {
2422 let info_chunk = chunks
2423 .iter()
2424 .find(|c| &c.id == b"INFO")
2425 .ok_or(DocError::MissingChunk("INFO"))?;
2426
2427 let info = PageInfo::parse(info_chunk.data)?;
2428
2429 let raw_chunks: Vec<RawChunk> = chunks
2431 .iter()
2432 .map(|c| RawChunk {
2433 id: c.id,
2434 data: c.data.to_vec(),
2435 })
2436 .collect();
2437
2438 Ok(DjVuPage {
2439 info,
2440 chunks: ChunkStore::Eager(raw_chunks),
2441 index,
2442 shared_djbz,
2443 render_layers: std::sync::OnceLock::new(),
2444 resource_limits: None,
2445 })
2446}
2447
2448fn page_info_from_iw44_first_chunk(
2451 form_type: &[u8; 4],
2452 payload: &[u8],
2453) -> Result<PageInfo, DocError> {
2454 if payload.len() < 9 {
2455 return Err(DocError::Malformed(
2456 "legacy IW44 first chunk header truncated",
2457 ));
2458 }
2459 let serial = payload[0];
2460 if serial != 0 {
2461 return Err(DocError::Malformed(
2462 "legacy IW44 first chunk must have serial 0",
2463 ));
2464 }
2465 let majver = payload[2];
2466 let is_grayscale = (majver >> 7) != 0;
2467 match (form_type, is_grayscale) {
2468 (b"BM44", true) | (b"PM44", false) => {}
2469 (b"BM44", false) => {
2470 return Err(DocError::Malformed(
2471 "FORM:BM44 requires a grayscale IW44 bitstream",
2472 ));
2473 }
2474 (b"PM44", true) => {
2475 return Err(DocError::Malformed(
2476 "FORM:PM44 requires a color IW44 bitstream",
2477 ));
2478 }
2479 _ => {
2480 return Err(DocError::Malformed("unexpected legacy IW44 form type"));
2481 }
2482 }
2483 let width = u16::from_be_bytes([payload[4], payload[5]]);
2484 let height = u16::from_be_bytes([payload[6], payload[7]]);
2485 if width == 0 || height == 0 {
2486 return Err(DocError::Malformed("legacy IW44 zero dimension"));
2487 }
2488 let pixels = u64::from(width) * u64::from(height);
2489 if pixels > 64 * 1024 * 1024 {
2490 return Err(DocError::Malformed("legacy IW44 image too large"));
2491 }
2492 Ok(PageInfo {
2493 width,
2494 height,
2495 dpi: 100,
2496 gamma: 2.2,
2497 rotation: crate::info::Rotation::None,
2498 })
2499}
2500
2501fn parse_legacy_iw44_page(
2503 form_type: &[u8; 4],
2504 chunks: &[IffChunk<'_>],
2505 index: usize,
2506) -> Result<DjVuPage, DocError> {
2507 let expected_id = match form_type {
2508 b"BM44" => *b"BM44",
2509 b"PM44" => *b"PM44",
2510 _ => {
2511 return Err(DocError::Malformed(
2512 "parse_legacy_iw44_page requires BM44 or PM44",
2513 ));
2514 }
2515 };
2516 if chunks.is_empty() {
2517 return Err(DocError::MissingChunk(match form_type {
2518 b"BM44" => "BM44",
2519 _ => "PM44",
2520 }));
2521 }
2522 for chunk in chunks {
2523 if chunk.id != expected_id {
2524 return Err(DocError::Malformed(
2525 "legacy IW44 form contains unexpected chunk id",
2526 ));
2527 }
2528 }
2529 let info = page_info_from_iw44_first_chunk(form_type, chunks[0].data)?;
2530 let raw_chunks: Vec<RawChunk> = chunks
2531 .iter()
2532 .map(|c| RawChunk {
2533 id: c.id,
2534 data: c.data.to_vec(),
2535 })
2536 .collect();
2537 #[cfg(feature = "std")]
2538 {
2539 Ok(DjVuPage {
2540 info,
2541 chunks: ChunkStore::Eager(raw_chunks),
2542 index,
2543 shared_djbz: None,
2544 render_layers: std::sync::OnceLock::new(),
2545 resource_limits: None,
2546 })
2547 }
2548 #[cfg(not(feature = "std"))]
2549 {
2550 Ok(DjVuPage {
2551 info,
2552 chunks: raw_chunks,
2553 index,
2554 shared_djbz: None,
2555 resource_limits: None,
2556 })
2557 }
2558}
2559
2560#[cfg(not(feature = "std"))]
2561fn parse_page_from_chunks(
2562 chunks: &[IffChunk<'_>],
2563 index: usize,
2564 shared_djbz: Option<Vec<u8>>,
2565) -> Result<DjVuPage, DocError> {
2566 let info_chunk = chunks
2567 .iter()
2568 .find(|c| &c.id == b"INFO")
2569 .ok_or(DocError::MissingChunk("INFO"))?;
2570
2571 let info = PageInfo::parse(info_chunk.data)?;
2572
2573 let raw_chunks: Vec<RawChunk> = chunks
2574 .iter()
2575 .map(|c| RawChunk {
2576 id: c.id,
2577 data: c.data.to_vec(),
2578 })
2579 .collect();
2580
2581 Ok(DjVuPage {
2582 info,
2583 chunks: raw_chunks,
2584 index,
2585 shared_djbz,
2586 resource_limits: None,
2587 })
2588}
2589
2590fn parse_sub_form(data: &[u8]) -> Result<Vec<IffChunk<'_>>, DocError> {
2595 if data.len() < 4 {
2596 return Err(DocError::Malformed("sub-form data too short"));
2597 }
2598 let body = data
2601 .get(4..)
2602 .ok_or(DocError::Malformed("sub-form body missing"))?;
2603 let chunks = parse_form_body(body).map_err(DocError::Iff)?;
2604 Ok(chunks)
2605}
2606
2607const MAX_NAVM_DEPTH: u32 = 256;
2611
2612fn parse_navm_bookmarks(chunks: &[IffChunk<'_>]) -> Result<Vec<DjVuBookmark>, DocError> {
2616 let navm_data = match chunks.iter().find(|c| &c.id == b"NAVM") {
2617 Some(c) => c.data,
2618 None => return Ok(vec![]),
2619 };
2620
2621 let decoded = bzz_decode(navm_data)?;
2622
2623 if decoded.len() < 2 {
2624 return Ok(vec![]);
2625 }
2626
2627 let b0 = *decoded
2628 .first()
2629 .ok_or(DocError::Malformed("NAVM total count byte 0"))?;
2630 let b1 = *decoded
2631 .get(1)
2632 .ok_or(DocError::Malformed("NAVM total count byte 1"))?;
2633 let total_count = u16::from_be_bytes([b0, b1]) as usize;
2634
2635 let mut pos = 2usize;
2636 let mut bookmarks = Vec::new();
2637 let mut decoded_count = 0usize;
2638
2639 while decoded_count < total_count {
2640 let bm = parse_bookmark_entry(&decoded, &mut pos, &mut decoded_count, 0)?;
2641 bookmarks.push(bm);
2642 }
2643
2644 Ok(bookmarks)
2645}
2646
2647fn parse_bookmark_entry(
2652 data: &[u8],
2653 pos: &mut usize,
2654 total_counter: &mut usize,
2655 depth: u32,
2656) -> Result<DjVuBookmark, DocError> {
2657 if depth > MAX_NAVM_DEPTH {
2661 return Err(DocError::Malformed("NAVM bookmark nesting too deep"));
2662 }
2663 if *pos >= data.len() {
2664 return Err(DocError::Malformed("NAVM bookmark entry truncated"));
2665 }
2666
2667 let n_children = *data
2669 .get(*pos)
2670 .ok_or(DocError::Malformed("NAVM children count"))? as usize;
2671 *pos += 1;
2672
2673 let title = read_navm_str(data, pos)?;
2674 let url = read_navm_str(data, pos)?;
2675 *total_counter += 1;
2676
2677 let mut children = Vec::with_capacity(n_children);
2679 for _ in 0..n_children {
2680 let child = parse_bookmark_entry(data, pos, total_counter, depth + 1)?;
2681 children.push(child);
2682 }
2683
2684 Ok(DjVuBookmark {
2685 title,
2686 url,
2687 children,
2688 })
2689}
2690
2691fn read_navm_str(data: &[u8], pos: &mut usize) -> Result<String, DocError> {
2697 if *pos + 3 > data.len() {
2698 return Err(DocError::Malformed("NAVM string length truncated"));
2699 }
2700 let len = ((*data.get(*pos).ok_or(DocError::Malformed("NAVM str"))? as usize) << 16)
2701 | ((*data.get(*pos + 1).ok_or(DocError::Malformed("NAVM str"))? as usize) << 8)
2702 | (*data.get(*pos + 2).ok_or(DocError::Malformed("NAVM str"))? as usize);
2703 *pos += 3;
2704
2705 let bytes = data
2706 .get(*pos..*pos + len)
2707 .ok_or(DocError::Malformed("NAVM string bytes truncated"))?;
2708 *pos += len;
2709
2710 Ok(crate::lenient_text::decode_lossy_string(bytes))
2711}
2712
2713#[cfg(test)]
2716mod tests {
2717 use super::*;
2718
2719 fn fixture_bytes(name: &str) -> Vec<u8> {
2720 std::fs::read(
2721 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2722 .join(format!("tests/fixtures/{name}")),
2723 )
2724 .unwrap_or_else(|_| panic!("fixture {name} should exist"))
2725 }
2726
2727 #[test]
2734 fn multi_incl_page_resolves_shared_dict() {
2735 let path =
2736 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/czech.djvu");
2737 let data = std::fs::read(path).unwrap();
2738 let doc = DjVuDocument::parse(&data).unwrap();
2739 let mask = doc
2740 .page(1)
2741 .unwrap()
2742 .extract_mask()
2743 .expect("mask decode must succeed")
2744 .expect("page 1 has an Sjbz mask");
2745 assert_eq!((mask.width, mask.height), (1095, 1750));
2746 let black: u64 = (0..mask.height)
2747 .map(|y| (0..mask.width).filter(|&x| mask.get(x, y)).count() as u64)
2748 .sum();
2749 assert_eq!(
2750 black, 308_624,
2751 "mask content must match the ddjvu reference"
2752 );
2753 }
2754
2755 #[test]
2759 fn deeply_nested_bookmarks_are_rejected_not_overflow() {
2760 let mut decoded = vec![0x00, 0x01];
2764 for _ in 0..400 {
2765 decoded.push(1); decoded.extend_from_slice(&[0, 0, 0]); decoded.extend_from_slice(&[0, 0, 0]); }
2769 decoded.push(0); decoded.extend_from_slice(&[0, 0, 0]);
2771 decoded.extend_from_slice(&[0, 0, 0]);
2772
2773 let mut pos = 2usize;
2774 let mut counter = 0usize;
2775 let r = parse_bookmark_entry(&decoded, &mut pos, &mut counter, 0);
2776 assert!(r.is_err(), "deep bookmark chain must error, not overflow");
2777 }
2778
2779 fn assets_path() -> std::path::PathBuf {
2780 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2781 .join("references/djvujs/library/assets")
2782 }
2783
2784 #[test]
2788 fn single_page_parse_and_metadata() {
2789 let data =
2790 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
2791 let doc = DjVuDocument::parse(&data).expect("parse should succeed");
2792
2793 assert_eq!(doc.page_count(), 1);
2794 let page = doc.page(0).expect("page 0 must exist");
2795 assert_eq!(page.width(), 181);
2796 assert_eq!(page.height(), 240);
2797 assert_eq!(page.dpi(), 100);
2798 assert!((page.gamma() - 2.2).abs() < 0.01, "gamma should be ~2.2");
2799 }
2800
2801 #[test]
2803 fn single_page_out_of_range() {
2804 let data =
2805 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
2806 let doc = DjVuDocument::parse(&data).expect("parse should succeed");
2807 let err = doc.page(1).expect_err("page 1 should be out of range");
2808 assert!(
2809 matches!(err, DocError::PageOutOfRange { index: 1, count: 1 }),
2810 "unexpected error: {err:?}"
2811 );
2812 }
2813
2814 #[test]
2821 fn paired_payload_prefers_compressed_z_chunk() {
2822 let raw = b"the quick brown fox".as_slice();
2823 let z = crate::bzz_encode::bzz_encode(raw);
2824 let out = decode_paired_payload(Some(&z), Some(b"ignored raw"))
2826 .expect("bzz decode should succeed");
2827 assert_eq!(out.as_deref(), Some(raw));
2828 }
2829
2830 #[test]
2831 fn paired_payload_falls_back_to_raw_a_chunk() {
2832 let raw = b"plain uncompressed payload".as_slice();
2833 let out = decode_paired_payload(None, Some(raw)).expect("raw passthrough");
2834 assert_eq!(out.as_deref(), Some(raw));
2835 }
2836
2837 #[test]
2838 fn paired_payload_missing_both_is_none() {
2839 assert_eq!(decode_paired_payload(None, None).expect("none"), None);
2840 }
2841
2842 #[test]
2843 fn paired_payload_empty_chunk_is_placeholder_none() {
2844 assert_eq!(
2847 decode_paired_payload(Some(&[]), None).expect("empty z"),
2848 None
2849 );
2850 assert_eq!(
2851 decode_paired_payload(None, Some(&[])).expect("empty a"),
2852 None
2853 );
2854 }
2855
2856 #[test]
2857 fn paired_payload_invalid_bzz_errors() {
2858 let result = decode_paired_payload(Some(&[0xff, 0x00, 0x13, 0x37]), None);
2861 assert!(result.is_err(), "invalid BZZ must error, got {result:?}");
2862 }
2863
2864 fn make_info(width: u16, height: u16) -> Vec<u8> {
2866 let mut v = Vec::with_capacity(10);
2867 v.extend_from_slice(&width.to_be_bytes());
2868 v.extend_from_slice(&height.to_be_bytes());
2869 v.extend_from_slice(&[0, 0]); v.extend_from_slice(&100u16.to_le_bytes()); v.push(22); v.push(0); v
2874 }
2875
2876 #[test]
2881 fn metadata_cache_repeated_access_is_consistent() {
2882 let data = std::fs::read(
2883 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/links.djvu"),
2884 )
2885 .unwrap();
2886 let doc = DjVuDocument::parse(&data).unwrap();
2887 let page = doc.page(0).unwrap();
2888
2889 let a1 = page.annotations().unwrap();
2890 let a2 = page.annotations().unwrap();
2891 assert_eq!(
2892 a1.as_ref().map(|(_, m)| m.len()),
2893 a2.as_ref().map(|(_, m)| m.len())
2894 );
2895 let s1 = page.annotations_shared().unwrap();
2896 let s2 = page.annotations_shared().unwrap();
2897 if let (Some(s1), Some(s2)) = (s1, s2) {
2898 assert!(
2899 std::sync::Arc::ptr_eq(&s1, &s2),
2900 "warm hits must share one decode"
2901 );
2902 }
2903 let h1 = page.hyperlinks().unwrap();
2904 let h2 = page.hyperlinks().unwrap();
2905 assert_eq!(h1.len(), h2.len());
2906 }
2907
2908 #[test]
2911 fn metadata_cache_does_not_cache_errors() {
2912 let bogus = [0xFFu8, 0x00, 0x12, 0x34, 0x56];
2915 let page = page_with_chunks(&[(b"TXTz", &bogus)]);
2916 assert!(page.text_layer().is_err());
2917 assert!(
2918 page.text_layer().is_err(),
2919 "second call must error identically"
2920 );
2921 }
2922
2923 fn page_with_chunks(extra: &[(&[u8; 4], &[u8])]) -> DjVuPage {
2924 let info = make_info(64, 48);
2925 let mut chunks = Vec::new();
2926 chunks.push(IffChunk {
2927 id: *b"INFO",
2928 data: &info,
2929 });
2930 for (id, data) in extra {
2931 chunks.push(IffChunk { id: **id, data });
2932 }
2933 parse_page_from_chunks(&chunks, 0, None).expect("page should build")
2934 }
2935
2936 #[test]
2937 fn parse_with_options_rejects_exceeded_page_count_before_decode() {
2938 let data = fixture_bytes("boy.djvu");
2939 let err = DjVuDocument::parse_with_options(
2940 &data,
2941 &crate::resource_limits::ParseOptions {
2942 limits: Some(crate::resource_limits::ResourceLimits {
2943 max_pages: Some(0),
2944 ..Default::default()
2945 }),
2946 },
2947 )
2948 .expect_err("parse should fail on page-count limit");
2949 assert!(matches!(err, DocError::ResourceLimit(_)));
2950 }
2951
2952 #[test]
2953 fn parse_with_options_stores_limits_for_render_inheritance() {
2954 let data = fixture_bytes("boy.djvu");
2955 let limits = crate::resource_limits::ResourceLimits {
2956 max_render_pixels: Some(100_000),
2957 ..Default::default()
2958 };
2959 let doc = DjVuDocument::parse_with_options(
2960 &data,
2961 &crate::resource_limits::ParseOptions {
2962 limits: Some(limits),
2963 },
2964 )
2965 .expect("parse should succeed");
2966 assert_eq!(doc.resource_limits(), Some(limits));
2967 assert_eq!(doc.page(0).unwrap().resource_limits(), Some(limits));
2968 }
2969
2970 #[test]
2971 fn chunk_payload_decodes_compressed_txtz() {
2972 let raw = b"decoded text-layer payload".as_slice();
2973 let z = crate::bzz_encode::bzz_encode(raw);
2974 let page = page_with_chunks(&[(b"TXTz", &z)]);
2975 let out = page
2976 .chunk_payload(b"TXTz", b"TXTa")
2977 .expect("chunk_payload should succeed");
2978 assert_eq!(out.as_deref(), Some(raw));
2979 }
2980
2981 #[test]
2982 fn chunk_payload_passes_through_raw_txta() {
2983 let raw = b"raw text-layer payload".as_slice();
2984 let page = page_with_chunks(&[(b"TXTa", raw)]);
2985 let out = page
2986 .chunk_payload(b"TXTz", b"TXTa")
2987 .expect("chunk_payload should succeed");
2988 assert_eq!(out.as_deref(), Some(raw));
2989 }
2990
2991 #[test]
2992 fn chunk_payload_missing_chunk_is_none() {
2993 let page = page_with_chunks(&[]); let out = page
2995 .chunk_payload(b"TXTz", b"TXTa")
2996 .expect("chunk_payload should succeed");
2997 assert_eq!(out, None);
2998 }
2999
3000 #[test]
3002 fn single_page_no_thumbnail() {
3003 let data =
3004 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3005 let doc = DjVuDocument::parse(&data).expect("parse should succeed");
3006 let page = doc.page(0).expect("page 0 must exist");
3007 let thumb = page.thumbnail().expect("thumbnail() should not error");
3009 assert!(
3010 thumb.is_none(),
3011 "single-page chicken.djvu has no TH44 chunks"
3012 );
3013 }
3014
3015 #[test]
3017 fn single_page_dimensions() {
3018 let data =
3019 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3020 let doc = DjVuDocument::parse(&data).expect("parse should succeed");
3021 let page = doc.page(0).unwrap();
3022 assert_eq!(page.dimensions(), (181, 240));
3023 }
3024
3025 #[test]
3027 fn multipage_bundled_page_count() {
3028 let data = std::fs::read(assets_path().join("DjVu3Spec_bundled.djvu"))
3029 .expect("DjVu3Spec_bundled.djvu must exist");
3030 let doc = DjVuDocument::parse(&data).expect("bundled parse should succeed");
3031 assert!(
3033 doc.page_count() > 1,
3034 "bundled document should have more than 1 page, got {}",
3035 doc.page_count()
3036 );
3037 }
3038
3039 #[test]
3041 fn multipage_bundled_page_metadata() {
3042 let data = std::fs::read(assets_path().join("DjVu3Spec_bundled.djvu"))
3043 .expect("DjVu3Spec_bundled.djvu must exist");
3044 let doc = DjVuDocument::parse(&data).expect("bundled parse should succeed");
3045
3046 let page0 = doc.page(0).expect("page 0 must exist");
3047 assert!(page0.width() > 0, "page width must be non-zero");
3048 assert!(page0.height() > 0, "page height must be non-zero");
3049 assert!(page0.dpi() > 0, "page dpi must be non-zero");
3050 }
3051
3052 #[test]
3054 fn navm_bookmarks_present() {
3055 let data =
3056 std::fs::read(assets_path().join("navm_fgbz.djvu")).expect("navm_fgbz.djvu must exist");
3057 let doc = DjVuDocument::parse(&data).expect("parse should succeed");
3058 let bm = doc.bookmarks();
3060 assert!(
3061 !bm.is_empty(),
3062 "navm_fgbz.djvu should have at least one bookmark"
3063 );
3064 }
3065
3066 #[test]
3068 fn no_navm_returns_empty_bookmarks() {
3069 let data =
3070 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3071 let doc = DjVuDocument::parse(&data).expect("parse should succeed");
3072 assert!(
3073 doc.bookmarks().is_empty(),
3074 "chicken.djvu has no NAVM — bookmarks should be empty"
3075 );
3076 }
3077
3078 #[test]
3084 fn indirect_document_with_resolver() {
3085 let chicken_data =
3087 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3088 let djvm_data = build_indirect_djvm_bytes("chicken.djvu");
3090
3091 let resolver = |name: &str| -> Result<Vec<u8>, DocError> {
3092 if name == "chicken.djvu" {
3093 Ok(chicken_data.clone())
3094 } else {
3095 Err(DocError::IndirectResolve(name.to_string()))
3096 }
3097 };
3098
3099 let doc = DjVuDocument::parse_with_resolver(&djvm_data, Some(resolver))
3100 .expect("indirect parse should succeed");
3101
3102 assert_eq!(doc.page_count(), 1);
3103 let page = doc.page(0).unwrap();
3104 assert_eq!(page.width(), 181);
3105 assert_eq!(page.height(), 240);
3106 }
3107
3108 #[test]
3110 fn indirect_document_no_resolver_returns_error() {
3111 let djvm_data = build_indirect_djvm_bytes("chicken.djvu");
3112 let err = DjVuDocument::parse(&djvm_data).expect_err("should fail without resolver");
3113 assert!(
3114 matches!(err, DocError::NoResolver),
3115 "expected NoResolver, got {err:?}"
3116 );
3117 }
3118
3119 #[test]
3126 fn page_is_lazy_no_decode_before_thumbnail() {
3127 let data =
3128 std::fs::read(assets_path().join("boy_jb2.djvu")).expect("boy_jb2.djvu must exist");
3129 let doc = DjVuDocument::parse(&data).expect("parse should succeed");
3130 let page = doc.page(0).expect("page 0 must exist");
3131
3132 assert!(!page.chunk_slice().is_empty(), "chunks must be available");
3135
3136 let thumb = page.thumbnail().expect("thumbnail() should not error");
3138 assert!(thumb.is_none());
3139 }
3140
3141 #[test]
3143 fn not_djvu_returns_error() {
3144 let data = crate::iff::partial_emit(*b"XXXX", &[crate::iff::EmitPart::Verbatim(b"XXXX")])
3147 .expect("fits within u32");
3148 let err = DjVuDocument::parse(&data).expect_err("should fail");
3149 assert!(
3150 matches!(err, DocError::NotDjVu(_) | DocError::Iff(_)),
3151 "expected NotDjVu or Iff error, got {err:?}"
3152 );
3153 }
3154
3155 fn build_indirect_djvm_bytes(_page_name: &str) -> Vec<u8> {
3164 let bzz_meta: &[u8] = &[
3168 0xff, 0xff, 0xed, 0xbf, 0x8a, 0x1f, 0xbe, 0xad, 0x14, 0x57, 0x10, 0xc9, 0x63, 0x19,
3169 0x11, 0xf0, 0x85, 0x28, 0x12, 0x8a, 0xbf,
3170 ];
3171
3172 let mut dirm_data = Vec::new();
3173 dirm_data.push(0x00); dirm_data.push(0x00); dirm_data.push(0x01); dirm_data.extend_from_slice(bzz_meta);
3177
3178 build_djvm_with_dirm(&dirm_data)
3179 }
3180
3181 fn build_djvm_with_dirm(dirm_data: &[u8]) -> Vec<u8> {
3182 let dirm = crate::iff::Chunk::Leaf {
3184 id: *b"DIRM",
3185 data: dirm_data.to_vec(),
3186 };
3187 crate::iff::partial_emit(*b"DJVM", &[crate::iff::EmitPart::Chunk(&dirm)])
3188 .expect("fits within u32")
3189 }
3190
3191 #[test]
3193 fn parse_bundled_djvm_with_short_sub_form_returns_malformed() {
3194 use crate::dirm::DirmPayload;
3195 let dirm_payload = DirmPayload::build_bundled(1, &[0x01], &["p0001.djvu".to_string()], &[]);
3197 let dirm = crate::iff::Chunk::Leaf {
3198 id: *b"DIRM",
3199 data: dirm_payload.encode(),
3200 };
3201 let short_form_bytes: &[u8] = b"FORM\x00\x00\x00\x02AB";
3204 let djvm = crate::iff::partial_emit(
3205 *b"DJVM",
3206 &[
3207 crate::iff::EmitPart::Chunk(&dirm),
3208 crate::iff::EmitPart::Verbatim(short_form_bytes),
3209 ],
3210 )
3211 .expect("fits within u32");
3212
3213 let err = DjVuDocument::parse(&djvm).expect_err("short sub-form must error");
3214 assert!(
3215 matches!(err, DocError::Malformed(_)),
3216 "expected Malformed, got {err:?}"
3217 );
3218 }
3219
3220 #[test]
3224 fn page_raw_chunk_info_present() {
3225 let data =
3226 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3227 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3228 let page = doc.page(0).expect("page 0 must exist");
3229
3230 let info = page.raw_chunk(b"INFO").expect("INFO chunk must be present");
3232 assert_eq!(info.len(), 10, "INFO chunk is always 10 bytes");
3233 }
3234
3235 #[test]
3237 fn page_raw_chunk_absent() {
3238 let data =
3239 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3240 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3241 let page = doc.page(0).expect("page 0 must exist");
3242
3243 assert!(
3244 page.raw_chunk(b"XXXX").is_none(),
3245 "unknown chunk type must return None"
3246 );
3247 }
3248
3249 #[test]
3251 fn page_all_chunks_bg44_multiple() {
3252 let data = std::fs::read(
3254 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3255 .join("tests/fixtures/big-scanned-page.djvu"),
3256 )
3257 .expect("big-scanned-page.djvu must exist");
3258 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3259 let page = doc.page(0).expect("page 0 must exist");
3260
3261 let bg44 = page.all_chunks(b"BG44");
3262 assert!(
3263 bg44.len() >= 2,
3264 "colour page must have ≥2 BG44 chunks, got {}",
3265 bg44.len()
3266 );
3267
3268 for (i, chunk) in bg44.iter().enumerate() {
3270 assert!(!chunk.is_empty(), "BG44 chunk {i} must not be empty");
3271 }
3272 }
3273
3274 #[test]
3276 fn page_chunk_ids_includes_info() {
3277 let data =
3278 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3279 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3280 let page = doc.page(0).expect("page 0 must exist");
3281
3282 let ids = page.chunk_ids();
3283 assert!(!ids.is_empty(), "chunk_ids must not be empty");
3284 assert!(
3285 ids.contains(b"INFO"),
3286 "chunk_ids must include INFO, got: {:?}",
3287 ids.iter()
3288 .map(|id| std::str::from_utf8(id).unwrap_or("????"))
3289 .collect::<Vec<_>>()
3290 );
3291 }
3292
3293 #[test]
3295 fn document_raw_chunk_single_page() {
3296 let data =
3297 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3298 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3299
3300 let info = doc
3302 .raw_chunk(b"INFO")
3303 .expect("document must expose INFO chunk");
3304 assert_eq!(info.len(), 10);
3305 }
3306
3307 #[test]
3312 fn djvi_shared_dict_parsed_from_bundled_djvm() {
3313 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3314 .join("tests/fixtures/DjVu3Spec_bundled.djvu");
3315 let data = std::fs::read(&path).expect("DjVu3Spec_bundled.djvu must exist");
3316 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3317
3318 assert!(doc.page_count() > 0, "document must have pages");
3319
3320 let pages_with_dict = doc.pages.iter().filter(|p| p.shared_djbz.is_some()).count();
3322 assert!(
3323 pages_with_dict > 0,
3324 "at least one page must have a resolved shared DJVI dict"
3325 );
3326 }
3327
3328 #[test]
3330 fn djvi_incl_page_mask_renders_ok() {
3331 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3332 .join("tests/fixtures/DjVu3Spec_bundled.djvu");
3333 let data = std::fs::read(&path).expect("DjVu3Spec_bundled.djvu must exist");
3334 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3335
3336 let page = doc
3338 .pages
3339 .iter()
3340 .find(|p| p.shared_djbz.is_some())
3341 .expect("at least one page must have a shared dict");
3342
3343 let mask = page
3344 .extract_mask()
3345 .expect("extract_mask must succeed for INCL page");
3346 assert!(mask.is_some(), "INCL page must have a JB2 mask");
3347 let bm = mask.unwrap();
3348 assert!(
3349 bm.width > 0 && bm.height > 0,
3350 "mask must have non-zero dimensions"
3351 );
3352 }
3353
3354 #[test]
3358 fn mask_sub4_matches_extract_mask_then_downsample() {
3359 let data = std::fs::read(
3360 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3361 .join("tests/fixtures/boy_jb2.djvu"),
3362 )
3363 .expect("boy_jb2.djvu must exist");
3364 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3365 let page = doc.page(0).expect("page 0 must exist");
3366
3367 let full = page
3368 .extract_mask()
3369 .expect("extract_mask must succeed")
3370 .expect("boy_jb2.djvu page must have a JB2 mask");
3371 let expected = crate::djvu_render::downsample_mask_4x(&full);
3372
3373 let actual = page
3374 .extract_mask_sub4()
3375 .expect("extract_mask_sub4 must succeed")
3376 .expect("boy_jb2.djvu page must have a JB2 mask");
3377
3378 assert_eq!(expected.width, actual.width);
3379 assert_eq!(expected.height, actual.height);
3380 assert_eq!(expected.data, actual.data, "sub4 mask mismatch");
3381 }
3382
3383 #[test]
3387 fn mask_sub4_matches_extract_mask_then_downsample_shared_dict() {
3388 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3389 .join("tests/fixtures/DjVu3Spec_bundled.djvu");
3390 let data = std::fs::read(&path).expect("DjVu3Spec_bundled.djvu must exist");
3391 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3392 let page = doc
3393 .pages
3394 .iter()
3395 .find(|p| p.shared_djbz.is_some())
3396 .expect("at least one page must have a shared dict");
3397
3398 let full = page
3399 .extract_mask()
3400 .expect("extract_mask must succeed")
3401 .expect("page must have a JB2 mask");
3402 let expected = crate::djvu_render::downsample_mask_4x(&full);
3403
3404 let actual = page
3405 .extract_mask_sub4()
3406 .expect("extract_mask_sub4 must succeed")
3407 .expect("page must have a JB2 mask");
3408
3409 assert_eq!(expected.width, actual.width);
3410 assert_eq!(expected.height, actual.height);
3411 assert_eq!(expected.data, actual.data, "sub4 mask mismatch");
3412 }
3413
3414 #[test]
3416 fn no_regression_non_incl_pages() {
3417 let data = std::fs::read(
3419 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3420 .join("tests/fixtures/boy_jb2.djvu"),
3421 )
3422 .expect("boy_jb2.djvu must exist");
3423 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3424 let page = doc.page(0).expect("page 0 must exist");
3425 assert!(
3426 page.shared_djbz.is_none(),
3427 "single-page DJVU has no shared dict"
3428 );
3429 let mask = page.extract_mask().expect("extract_mask must succeed");
3430 assert!(mask.is_some(), "boy_jb2.djvu page must have a JB2 mask");
3431 }
3432
3433 #[test]
3442 fn parse_carte_with_short_info_chunk() {
3443 let data = std::fs::read(
3444 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/carte.djvu"),
3445 )
3446 .expect("carte.djvu must exist");
3447 let doc = DjVuDocument::parse(&data).expect("carte.djvu must parse despite short INFO");
3448 assert_eq!(doc.page_count(), 1);
3449 let page = doc.page(0).expect("page 0 must exist");
3450 assert_eq!(page.width(), 4200);
3451 assert_eq!(page.height(), 2556);
3452 }
3453
3454 #[test]
3456 fn page_raw_chunk_info_roundtrip() {
3457 let data =
3458 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3459 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3460 let page = doc.page(0).expect("page 0 must exist");
3461
3462 let raw_info = page.raw_chunk(b"INFO").expect("INFO chunk must be present");
3463 let reparsed = crate::info::PageInfo::parse(raw_info).expect("re-parse must succeed");
3464 assert_eq!(reparsed.width, page.width() as u16);
3465 assert_eq!(reparsed.height, page.height() as u16);
3466 assert_eq!(reparsed.dpi, page.dpi());
3467 }
3468
3469 #[test]
3473 fn page_byte_range_single_page_covers_full_buffer() {
3474 let data =
3475 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3476 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3477
3478 let r = doc.page_byte_range(0).expect("page 0 must have a range");
3479 assert_eq!(r.start, 0);
3480 assert_eq!(r.end, data.len() as u64);
3481
3482 assert!(
3483 doc.page_byte_range(1).is_none(),
3484 "out-of-range index returns None"
3485 );
3486 }
3487
3488 #[test]
3491 fn page_byte_range_bundled_djvm_round_trips() {
3492 let path = assets_path().join("DjVu3Spec_bundled.djvu");
3493 let Ok(data) = std::fs::read(&path) else {
3494 eprintln!("skip: {} missing", path.display());
3495 return;
3496 };
3497 let doc = DjVuDocument::parse(&data).expect("bundled DJVM parse must succeed");
3498
3499 let mut prev_end = 0u64;
3500 for i in 0..doc.page_count() {
3501 let r = doc
3502 .page_byte_range(i)
3503 .unwrap_or_else(|| panic!("page {i} must have a range"));
3504 assert!(r.end <= data.len() as u64, "page {i} range OOB");
3505 assert!(r.start < r.end, "page {i} range empty");
3506 assert!(r.start >= prev_end, "page {i} overlaps previous");
3507 prev_end = r.end;
3508
3509 let slice = &data[r.start as usize..r.end as usize];
3511 assert_eq!(&slice[..4], b"FORM", "page {i} range must start with FORM");
3512 }
3513 }
3514
3515 #[test]
3516 fn page_thumbnail_with_th44_data() {
3517 let carte = std::fs::read(assets_path().join("carte.djvu")).unwrap();
3520 let th44_pos = carte.windows(4).position(|w| w == b"TH44");
3522 if let Some(pos) = th44_pos
3523 && pos + 8 <= carte.len()
3524 {
3525 let chunk_len = u32::from_be_bytes([
3526 carte[pos + 4],
3527 carte[pos + 5],
3528 carte[pos + 6],
3529 carte[pos + 7],
3530 ]) as usize;
3531 let chunk_data = carte.get(pos + 8..pos + 8 + chunk_len).unwrap_or(&[]);
3532 if !chunk_data.is_empty() {
3533 let page = page_with_chunks(&[(b"TH44", chunk_data)]);
3534 let thumb = page.thumbnail();
3536 assert!(thumb.is_ok(), "thumbnail decode should not error");
3537 }
3539 }
3540 }
3541
3542 #[test]
3543 fn extract_mask_from_smmr_chunk() {
3544 use crate::chunk_encode::{ChunkEncoder, SmmrChunk};
3547 let mut bm = crate::bitmap::Bitmap::new(8, 8);
3548 bm.set_black(2, 2);
3549 let smmr_chunk = SmmrChunk(&bm).encode_chunk().unwrap();
3550 let page = page_with_chunks(&[(b"Smmr", &smmr_chunk.payload)]);
3551 let result = page.extract_mask().unwrap();
3552 assert!(result.is_some(), "Smmr page should have a mask");
3553 assert_eq!(result.unwrap().width, 8);
3554 }
3555
3556 #[test]
3557 fn extract_background_returns_none_for_jb2_only_page() {
3558 let jb2_data = std::fs::read(assets_path().join("boy_jb2.djvu")).unwrap();
3561 let doc = DjVuDocument::parse(&jb2_data).unwrap();
3562 let page = doc.page(0).unwrap();
3563 let bg = page.extract_background().unwrap();
3564 assert!(bg.is_none(), "JB2-only page should have no background");
3565 }
3566
3567 #[test]
3568 fn extract_mask_indexed_smmr_path() {
3569 use crate::chunk_encode::{ChunkEncoder, SmmrChunk};
3571 let mut bm = crate::bitmap::Bitmap::new(4, 4);
3572 bm.set_black(1, 1);
3573 let smmr_chunk = SmmrChunk(&bm).encode_chunk().unwrap();
3574 let page = page_with_chunks(&[(b"Smmr", &smmr_chunk.payload)]);
3575 let result = page.extract_mask_indexed().unwrap();
3576 assert!(result.is_some());
3577 let (mask, indices) = result.unwrap();
3578 assert_eq!(mask.width, 4);
3579 assert_eq!(indices.len(), 4 * 4);
3580 }
3581
3582 #[test]
3583 fn extract_mask_indexed_no_chunks_returns_none() {
3584 let page = page_with_chunks(&[]);
3586 let result = page.extract_mask_indexed().unwrap();
3587 assert!(result.is_none());
3588 }
3589
3590 #[test]
3591 fn extract_background_decodes_iw44_from_color_page() {
3592 let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3594 let doc = DjVuDocument::parse(&data).unwrap();
3595 let page = doc.page(0).unwrap();
3596 let bg = page.extract_background().unwrap();
3597 assert!(bg.is_some(), "chicken.djvu page should have a background");
3598 let pm = bg.unwrap();
3599 assert!(pm.width > 0 && pm.height > 0);
3600 }
3601
3602 #[test]
3603 fn djvu_page_debug_impl_does_not_panic() {
3604 let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3605 let doc = DjVuDocument::parse(&data).unwrap();
3606 let page = doc.page(0).unwrap();
3607 let s = format!("{page:?}");
3608 assert!(s.contains("DjVuPage"));
3609 }
3610
3611 #[test]
3612 fn page_index_returns_zero_for_first_page() {
3613 let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3614 let doc = DjVuDocument::parse(&data).unwrap();
3615 let page = doc.page(0).unwrap();
3616 assert_eq!(page.index(), 0);
3617 }
3618
3619 #[test]
3620 fn page_text_returns_some_for_text_page() {
3621 let data = std::fs::read(assets_path().join("colorbook.djvu")).unwrap();
3622 let doc = DjVuDocument::parse(&data).unwrap();
3623 let page = doc.page(0).unwrap();
3624 let t = page.text().unwrap();
3625 assert!(t.is_some(), "colorbook page 0 should have text");
3626 }
3627
3628 #[test]
3630 fn page_byte_range_out_of_range() {
3631 let data =
3632 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3633 let doc = DjVuDocument::parse(&data).expect("parse must succeed");
3634 assert!(doc.page_byte_range(99).is_none());
3635 }
3636
3637 #[test]
3639 #[cfg(feature = "mmap")]
3640 fn mmap_document_matches_parse() {
3641 let path = assets_path().join("chicken.djvu");
3642 let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
3643 let data = std::fs::read(&path).expect("read should succeed");
3644 let mem_doc = DjVuDocument::parse(&data).expect("parse should succeed");
3645
3646 assert_eq!(mmap_doc.page_count(), mem_doc.page_count());
3647 for i in 0..mmap_doc.page_count() {
3648 let mp = mmap_doc.page(i).unwrap();
3649 let pp = mem_doc.page(i).unwrap();
3650 assert_eq!(mp.width(), pp.width());
3651 assert_eq!(mp.height(), pp.height());
3652 assert_eq!(mp.dpi(), pp.dpi());
3653 }
3654 }
3655
3656 #[test]
3657 fn extract_foreground_returns_none_when_no_fg44() {
3658 let data = std::fs::read(assets_path().join("boy_jb2.djvu")).unwrap();
3660 let doc = DjVuDocument::parse(&data).unwrap();
3661 let fg = doc.page(0).unwrap().extract_foreground().unwrap();
3662 assert!(fg.is_none());
3663 }
3664
3665 #[test]
3666 fn metadata_returns_none_for_doc_without_meta_chunk() {
3667 let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3668 let doc = DjVuDocument::parse(&data).unwrap();
3669 let meta = doc.metadata().unwrap();
3670 assert!(meta.is_none());
3672 }
3673
3674 #[test]
3675 fn all_chunks_returns_matching_chunks() {
3676 let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3677 let doc = DjVuDocument::parse(&data).unwrap();
3678 let info = doc.all_chunks(b"INFO");
3680 assert!(!info.is_empty());
3681 let none = doc.all_chunks(b"XXXX");
3683 assert!(none.is_empty());
3684 }
3685
3686 #[test]
3687 fn chunk_ids_returns_nonempty_for_djvu() {
3688 let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
3689 let doc = DjVuDocument::parse(&data).unwrap();
3690 let ids = doc.chunk_ids();
3691 assert!(!ids.is_empty());
3692 }
3693
3694 #[test]
3695 #[cfg(feature = "mmap")]
3696 fn mmap_open_indirect_on_bundled_doc_succeeds() {
3697 let path = assets_path().join("chicken.djvu");
3698 let doc = MmapDocument::open_indirect(&path).expect("open_indirect should work on bundled");
3699 assert!(doc.page_count() > 0);
3700 }
3701
3702 #[test]
3703 #[cfg(feature = "mmap")]
3704 fn mmap_document_method_and_deref_are_reachable() {
3705 let path = assets_path().join("chicken.djvu");
3706 let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
3707 assert!(mmap_doc.document().page_count() > 0);
3709 let inner: &DjVuDocument = &mmap_doc;
3711 assert!(inner.page_count() > 0);
3712 }
3713
3714 #[test]
3718 #[cfg(all(feature = "mmap", unix))]
3719 fn mmap_advise_page_willneed_in_range_and_out_of_range() {
3720 let path = assets_path().join("chicken.djvu");
3721 let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
3722 mmap_doc
3723 .advise_page_willneed(0)
3724 .expect("advise on page 0 should not error");
3725 mmap_doc
3728 .advise_page_willneed(9_999)
3729 .expect("advise on an out-of-range page must be a harmless no-op");
3730 }
3731
3732 #[test]
3737 #[cfg(feature = "mmap")]
3738 fn mmap_into_document_pages_still_render_after_wrapper_dropped() {
3739 let path = assets_path().join("chicken.djvu");
3740 let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
3741 let doc = mmap_doc.into_document();
3742 let page = doc.page(0).expect("page 0 should exist");
3743 let pm = crate::djvu_render::render_pixmap(
3744 page,
3745 &crate::djvu_render::RenderOptions {
3746 width: page.width() as u32,
3747 height: page.height() as u32,
3748 ..crate::djvu_render::RenderOptions::default()
3749 },
3750 )
3751 .expect("render after into_document should succeed");
3752 assert!(pm.width > 0 && pm.height > 0);
3753 }
3754
3755 #[test]
3762 #[cfg(all(feature = "mmap", feature = "parallel"))]
3763 fn prefetch_page_warms_cache_and_ignores_out_of_range() {
3764 let path = assets_path().join("chicken.djvu");
3765 let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
3766 let doc = Arc::new(mmap_doc.into_document());
3767
3768 doc.prefetch_page(9_999); doc.prefetch_page(0);
3770
3771 std::thread::sleep(std::time::Duration::from_millis(200));
3774
3775 let page = doc.page(0).unwrap();
3776 assert!(
3779 page.render_cache_bytes() > 0,
3780 "prefetch_page should have populated the render cache"
3781 );
3782
3783 let pm = crate::djvu_render::render_pixmap(
3785 page,
3786 &crate::djvu_render::RenderOptions {
3787 width: page.width() as u32,
3788 height: page.height() as u32,
3789 ..crate::djvu_render::RenderOptions::default()
3790 },
3791 )
3792 .expect("render after prefetch should succeed");
3793 assert!(pm.width > 0 && pm.height > 0);
3794 }
3795
3796 #[test]
3797 fn metadata_returns_some_for_doc_with_meta_chunk() {
3798 use crate::iff::{Chunk, DjvuFile, emit};
3800 use crate::metadata::{DjVuMetadata, encode_metadata};
3801
3802 let info = make_info(100, 100);
3803 let meta = DjVuMetadata {
3804 author: Some("TestAuthor".into()),
3805 ..DjVuMetadata::default()
3806 };
3807 let meta_bytes = encode_metadata(&meta);
3808 if meta_bytes.is_empty() {
3809 return; }
3811
3812 let file = DjvuFile {
3813 root: Chunk::Form {
3814 secondary_id: *b"DJVU",
3815 length: 0, children: vec![
3817 Chunk::Leaf {
3818 id: *b"INFO",
3819 data: info,
3820 },
3821 Chunk::Leaf {
3822 id: *b"METa",
3823 data: meta_bytes,
3824 },
3825 ],
3826 },
3827 };
3828 let bytes = emit(&file);
3829 let doc = DjVuDocument::parse(&bytes).expect("parse should succeed");
3830 let m = doc.metadata().expect("metadata() should not error");
3831 assert!(
3832 m.is_some(),
3833 "metadata should be Some for a doc with METa chunk"
3834 );
3835 assert_eq!(m.unwrap().author.as_deref(), Some("TestAuthor"));
3836 }
3837
3838 #[test]
3839 fn extract_mask_uses_inline_djbz_when_present() {
3840 use crate::jb2_encode::{
3843 cluster_shared_symbols, encode_jb2_dict_with_shared, encode_jb2_djbz,
3844 };
3845
3846 let mut shape = crate::bitmap::Bitmap::new(8, 8);
3847 shape.set_black(2, 2);
3848 shape.set_black(3, 3);
3849 let shapes = cluster_shared_symbols(&[shape.clone(), shape.clone()], 2);
3850 if shapes.is_empty() {
3851 return; }
3853 let djbz_data = encode_jb2_djbz(&shapes);
3854 let sjbz_data = encode_jb2_dict_with_shared(&shape, &shapes);
3855
3856 let page = page_with_chunks(&[(b"Djbz", &djbz_data), (b"Sjbz", &sjbz_data)]);
3857 let result = page.extract_mask();
3858 assert!(
3859 result.is_ok(),
3860 "extract_mask with inline Djbz should succeed"
3861 );
3862 }
3863
3864 #[test]
3865 fn extract_mask_indexed_uses_inline_djbz_when_present() {
3866 use crate::jb2_encode::{
3868 cluster_shared_symbols, encode_jb2_dict_with_shared, encode_jb2_djbz,
3869 };
3870
3871 let mut shape = crate::bitmap::Bitmap::new(8, 8);
3872 shape.set_black(2, 2);
3873 shape.set_black(3, 3);
3874 let shapes = cluster_shared_symbols(&[shape.clone(), shape.clone()], 2);
3875 if shapes.is_empty() {
3876 return;
3877 }
3878 let djbz_data = encode_jb2_djbz(&shapes);
3879 let sjbz_data = encode_jb2_dict_with_shared(&shape, &shapes);
3880
3881 let page = page_with_chunks(&[(b"Djbz", &djbz_data), (b"Sjbz", &sjbz_data)]);
3882 let result = page.extract_mask_indexed();
3883 assert!(
3884 result.is_ok(),
3885 "extract_mask_indexed with inline Djbz should succeed"
3886 );
3887 }
3888
3889 #[test]
3891 fn parse_navm_bookmarks_short_decoded_returns_empty() {
3892 use crate::bzz_encode::bzz_encode;
3893 let bzz = bzz_encode(b"x");
3895 let chunk = crate::iff::IffChunk {
3896 id: *b"NAVM",
3897 data: &bzz,
3898 };
3899 let result = parse_navm_bookmarks(&[chunk]).unwrap();
3900 assert!(
3901 result.is_empty(),
3902 "NAVM with decoded < 2 bytes must yield empty bookmarks"
3903 );
3904 }
3905
3906 #[test]
3908 fn parse_navm_bookmarks_truncated_entry_returns_error() {
3909 use crate::bzz_encode::bzz_encode;
3910 let payload = vec![0x00, 0x01]; let bzz = bzz_encode(&payload);
3913 let chunk = crate::iff::IffChunk {
3914 id: *b"NAVM",
3915 data: &bzz,
3916 };
3917 let result = parse_navm_bookmarks(&[chunk]);
3918 assert!(
3919 result.is_err(),
3920 "NAVM with declared count > 0 but no entry data must error"
3921 );
3922 }
3923
3924 #[test]
3927 fn parse_navm_bookmarks_cp1252_title_is_lenient() {
3928 use crate::bzz_encode::bzz_encode;
3929 let title = b"Chapter 1 \x96 Intro";
3932 let mut payload = vec![0x00, 0x01, 0x00];
3933 payload.extend_from_slice(&[0x00, 0x00, title.len() as u8]);
3934 payload.extend_from_slice(title);
3935 payload.extend_from_slice(&[0x00, 0x00, 0x02]);
3936 payload.extend_from_slice(b"#1");
3937 let bzz = bzz_encode(&payload);
3938 let chunk = crate::iff::IffChunk {
3939 id: *b"NAVM",
3940 data: &bzz,
3941 };
3942 let bookmarks = parse_navm_bookmarks(&[chunk]).expect("CP1252 title must not abort");
3943 assert_eq!(bookmarks.len(), 1);
3944 assert_eq!(bookmarks[0].title, "Chapter 1 \u{2013} Intro");
3945 assert_eq!(bookmarks[0].url, "#1");
3946 }
3947
3948 #[test]
3951 fn parse_navm_bookmarks_string_length_truncated_returns_error() {
3952 use crate::bzz_encode::bzz_encode;
3953 let payload = vec![0x00, 0x01, 0x00]; let bzz = bzz_encode(&payload);
3958 let chunk = crate::iff::IffChunk {
3959 id: *b"NAVM",
3960 data: &bzz,
3961 };
3962 let result = parse_navm_bookmarks(&[chunk]);
3963 assert!(
3964 result.is_err(),
3965 "NAVM with truncated string length must error"
3966 );
3967 }
3968
3969 #[test]
3972 fn indirect_djvm_with_shared_djvi_entry_skips_to_page() {
3973 use crate::dirm::DirmPayload;
3974 let chicken_data =
3975 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
3976
3977 let dirm_payload = DirmPayload::build_indirect(
3979 2,
3980 &[0x00, 0x01],
3981 &["shared.djvi".to_string(), "page.djvu".to_string()],
3982 );
3983 let dirm_data = dirm_payload.encode();
3984 let djvm_data = build_djvm_with_dirm(&dirm_data);
3985
3986 let resolver = |name: &str| -> Result<Vec<u8>, DocError> {
3987 if name == "page.djvu" {
3988 Ok(chicken_data.clone())
3989 } else {
3990 Err(DocError::IndirectResolve(name.to_string()))
3991 }
3992 };
3993
3994 let doc = DjVuDocument::parse_with_resolver(&djvm_data, Some(resolver))
3995 .expect("indirect DJVM with shared entry must parse");
3996 assert_eq!(doc.page_count(), 1);
3997 let page = doc.page(0).unwrap();
3998 assert_eq!(page.width(), 181);
3999 }
4000
4001 #[test]
4004 fn typed_indirect_resolver_loads_shared_djvi_component() {
4005 use std::cell::RefCell;
4006
4007 use crate::dirm::DirmPayload;
4008 use crate::iff::{Chunk, EmitPart};
4009
4010 let chicken_data =
4011 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu exists");
4012
4013 let mut page_file = crate::iff::parse(&chicken_data).expect("parse page fixture");
4015 match &mut page_file.root {
4016 Chunk::Form {
4017 secondary_id,
4018 children,
4019 ..
4020 } if secondary_id == b"DJVU" => {
4021 children.insert(
4022 1,
4023 Chunk::Leaf {
4024 id: *b"INCL",
4025 data: b"shared.djvi".to_vec(),
4026 },
4027 );
4028 }
4029 _ => panic!("fixture must be FORM:DJVU"),
4030 }
4031 let page_bytes = crate::iff::emit(&page_file);
4032
4033 let dict_chunk = Chunk::Leaf {
4034 id: *b"Djbz",
4035 data: vec![0x01, 0x02],
4036 };
4037 let shared_bytes = crate::iff::partial_emit(*b"DJVI", &[EmitPart::Chunk(&dict_chunk)])
4038 .expect("shared component fits");
4039 let thumbnail_bytes = crate::iff::partial_emit(*b"THUM", &[]).expect("thumbnail fits");
4040
4041 let dirm = DirmPayload::build_indirect(
4042 3,
4043 &[0x00, 0x01, 0x02],
4044 &[
4045 "shared.djvi".to_string(),
4046 "page.djvu".to_string(),
4047 "thumb.thum".to_string(),
4048 ],
4049 );
4050 let dirm_chunk = Chunk::Leaf {
4051 id: *b"DIRM",
4052 data: dirm.encode(),
4053 };
4054 let djvm = crate::iff::partial_emit(*b"DJVM", &[EmitPart::Chunk(&dirm_chunk)])
4055 .expect("index fits");
4056
4057 let seen = RefCell::new(Vec::new());
4058 let resolver = |component: &ComponentId| {
4059 seen.borrow_mut().push(component.clone());
4060 match component.name.as_str() {
4061 "shared.djvi" => Ok(shared_bytes.clone()),
4062 "page.djvu" => Ok(page_bytes.clone()),
4063 "thumb.thum" => Ok(thumbnail_bytes.clone()),
4064 _ => Err(ComponentResolveError::Missing {
4065 component: component.clone(),
4066 }),
4067 }
4068 };
4069
4070 let doc = DjVuDocument::parse_with_component_resolver(&djvm, &resolver)
4071 .expect("typed indirect parse");
4072 assert_eq!(doc.page_count(), 1);
4073 assert!(doc.pages[0].shared_djbz.is_some());
4074 assert_eq!(
4075 seen.borrow().as_slice(),
4076 &[
4077 ComponentId::new("shared.djvi", ComponentKind::Shared),
4078 ComponentId::new("page.djvu", ComponentKind::Page),
4079 ComponentId::new("thumb.thum", ComponentKind::Thumbnail),
4080 ]
4081 );
4082 }
4083
4084 #[test]
4086 fn parse_from_dir_resolves_absolute_component_path() {
4087 use crate::dirm::DirmPayload;
4088 use crate::iff::{self as iff_mod, Chunk, EmitPart};
4089
4090 let chicken =
4092 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
4093 let tmp_dir = std::env::temp_dir();
4094 let abs_name = tmp_dir.join("djvu_rs_test_abs_component.djvu");
4095 std::fs::write(&abs_name, &chicken).expect("write tmp component");
4096 let abs_name_str = abs_name.to_str().unwrap().to_string();
4097
4098 let dirm_payload =
4099 DirmPayload::build_indirect(1, &[0x01], std::slice::from_ref(&abs_name_str));
4100 let dirm = Chunk::Leaf {
4101 id: *b"DIRM",
4102 data: dirm_payload.encode(),
4103 };
4104 let djvm =
4105 iff_mod::partial_emit(*b"DJVM", &[EmitPart::Chunk(&dirm)]).expect("fits within u32");
4106
4107 let doc = DjVuDocument::parse_from_dir(&djvm, &tmp_dir)
4108 .expect("absolute-path component must resolve");
4109 assert_eq!(doc.page_count(), 1);
4110 let _ = std::fs::remove_file(&abs_name);
4111 }
4112
4113 #[cfg(all(feature = "std", feature = "async"))]
4115 #[test]
4116 fn parse_single_page_with_shared_wrong_form_type_returns_not_djvu() {
4117 use crate::iff::{self as iff_mod, Chunk, DjvuFile};
4118
4119 let bytes = iff_mod::emit(&DjvuFile {
4120 root: Chunk::Form {
4121 secondary_id: *b"DJVI",
4122 length: 0,
4123 children: vec![],
4124 },
4125 });
4126 let err = DjVuDocument::parse_single_page_with_shared(&bytes, 0, None)
4127 .expect_err("FORM:DJVI must not be accepted as a page");
4128 assert!(
4129 matches!(err, DocError::NotDjVu(_)),
4130 "expected NotDjVu, got {err:?}"
4131 );
4132 }
4133
4134 #[test]
4139 fn bundled_djvm_out_of_bounds_dirm_offset_clears_page_byte_ranges() {
4140 use crate::dirm::DirmPayload;
4141 use crate::iff::{self as iff_mod, Chunk, EmitPart};
4142
4143 let chicken =
4144 std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
4145
4146 let mut dirm_payload = DirmPayload::build_bundled(1, &[0x01], &["p.djvu".to_string()], &[]);
4149 dirm_payload.offsets[0] = 0xFFFF_FFFF; let dirm_data = dirm_payload.encode();
4151
4152 let dirm = Chunk::Leaf {
4153 id: *b"DIRM",
4154 data: dirm_data,
4155 };
4156 let form_bytes = chicken
4158 .strip_prefix(b"AT&T")
4159 .expect("chicken.djvu must start with AT&T");
4160
4161 let djvm = iff_mod::partial_emit(
4162 *b"DJVM",
4163 &[EmitPart::Chunk(&dirm), EmitPart::Verbatim(form_bytes)],
4164 )
4165 .expect("fits within u32");
4166
4167 let doc = DjVuDocument::parse(&djvm).expect("DJVM with bad offset must still parse");
4168 assert_eq!(doc.page_count(), 1, "page must still be accessible");
4169 assert!(
4171 doc.page_byte_range(0).is_none(),
4172 "page_byte_range must be None when DIRM offset is out of bounds"
4173 );
4174 }
4175
4176 #[test]
4178 fn legacy_bm44_parses_as_one_page() {
4179 let data = std::fs::read(
4180 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4181 .join("tests/fixtures/legacy_bm44.djvu"),
4182 )
4183 .expect("legacy_bm44.djvu must exist");
4184 let doc = DjVuDocument::parse(&data).expect("BM44 must parse");
4185 assert_eq!(doc.page_count(), 1);
4186 let page = doc.page(0).unwrap();
4187 assert_eq!(page.dimensions(), (32, 32));
4188 assert_eq!(page.dpi(), 100);
4189 assert_eq!(page.bg44_chunks().len(), 3);
4190 }
4191
4192 #[test]
4194 fn legacy_pm44_parses_as_one_page() {
4195 let data = std::fs::read(
4196 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4197 .join("tests/fixtures/legacy_pm44.djvu"),
4198 )
4199 .expect("legacy_pm44.djvu must exist");
4200 let doc = DjVuDocument::parse(&data).expect("PM44 must parse");
4201 assert_eq!(doc.page_count(), 1);
4202 let page = doc.page(0).unwrap();
4203 assert_eq!(page.dimensions(), (181, 240));
4204 assert_eq!(page.dpi(), 100);
4205 assert!(!page.bg44_chunks().is_empty());
4206 }
4207
4208 #[test]
4210 fn legacy_bm44_empty_is_typed_error() {
4211 let data = crate::iff::partial_emit(*b"BM44", &[]).expect("fits within u32");
4212 let err = DjVuDocument::parse(&data).expect_err("empty BM44 must fail");
4213 assert!(matches!(err, DocError::MissingChunk("BM44")), "got {err:?}");
4214 }
4215
4216 #[test]
4218 fn legacy_bm44_truncated_header_is_typed_error() {
4219 use crate::iff::{Chunk, EmitPart};
4220 let chunk = Chunk::Leaf {
4221 id: *b"BM44",
4222 data: vec![0, 1, 0x81],
4223 };
4224 let data = crate::iff::partial_emit(*b"BM44", &[EmitPart::Chunk(&chunk)])
4225 .expect("fits within u32");
4226 let err = DjVuDocument::parse(&data).expect_err("truncated BM44 must fail");
4227 assert!(matches!(err, DocError::Malformed(_)), "got {err:?}");
4228 }
4229
4230 #[test]
4232 fn legacy_bm44_rejects_color_bitstream() {
4233 use crate::iff::{Chunk, EmitPart};
4234 let payload = vec![0, 1, 0x01, 2, 0, 8, 0, 8, 0];
4236 let chunk = Chunk::Leaf {
4237 id: *b"BM44",
4238 data: payload,
4239 };
4240 let data = crate::iff::partial_emit(*b"BM44", &[EmitPart::Chunk(&chunk)])
4241 .expect("fits within u32");
4242 let err = DjVuDocument::parse(&data).expect_err("color BM44 must fail");
4243 assert!(matches!(err, DocError::Malformed(_)), "got {err:?}");
4244 }
4245}