Skip to main content

boox_note_parser/
lib.rs

1use std::{collections::HashMap, io::Read};
2
3use raqote::{DrawOptions, DrawTarget, LineCap, LineJoin, Source, StrokeStyle};
4
5use crate::{
6    error::{Error, Result},
7    id::{NoteUuid, PageModelUuid, PageUuid, PointsUuid, ShapeGroupUuid, VirtualPageUuid},
8    note_tree::{NoteMetadata, NoteTree},
9    page_model::{PageModel, PageModelGroup},
10    shape::ShapeGroup,
11    utils::convert_timestamp_to_datetime,
12    virtual_doc::VirtualDoc,
13    virtual_page::VirtualPage,
14};
15
16mod container;
17mod json;
18mod note_tree;
19mod page_model;
20mod utils;
21mod virtual_doc;
22
23pub mod error;
24pub mod extra;
25pub mod id;
26pub mod note_assets;
27pub mod points;
28pub mod resource;
29pub mod shape;
30pub mod template;
31pub mod virtual_page;
32
33#[derive(Debug, Clone)]
34struct QuickPenRenderStyle {
35    pen_type: u8,
36    width: f32,
37    color_argb: u32,
38}
39
40#[derive(Debug, Clone)]
41struct PageStyleContext {
42    default_color_argb: u32,
43    note_pen_width: f32,
44    pen_width_map: HashMap<u8, f32>,
45    quick_pen_styles: Vec<QuickPenRenderStyle>,
46}
47
48impl PageStyleContext {
49    fn from_note_metadata(metadata: &NoteMetadata) -> Self {
50        Self {
51            default_color_argb: metadata.pen_settings.fill_color,
52            note_pen_width: metadata.pen_width,
53            pen_width_map: metadata.pen_settings.pen_width_map.clone(),
54            quick_pen_styles: metadata
55                .pen_settings
56                .quick_pen_list
57                .quick_pens
58                .iter()
59                .map(|pen| QuickPenRenderStyle {
60                    pen_type: pen.type_,
61                    width: pen.width,
62                    color_argb: pen.color,
63                })
64                .collect(),
65        }
66    }
67
68    fn fallback_width(&self) -> f32 {
69        if self.note_pen_width.is_finite() && self.note_pen_width > 0.0 {
70            self.note_pen_width
71        } else {
72            1.0
73        }
74    }
75
76    fn resolve_stroke_width(&self, shape_width: f32, pen_type: Option<u8>) -> f32 {
77        if shape_width.is_finite() && shape_width > 0.0 {
78            return shape_width;
79        }
80
81        if let Some(pen_type) = pen_type {
82            if let Some(width) = self.pen_width_map.get(&pen_type) {
83                if width.is_finite() && *width > 0.0 {
84                    return *width;
85                }
86            }
87        }
88
89        self.fallback_width()
90    }
91
92    fn resolve_stroke_color(
93        &self,
94        shape_color_argb: u32,
95        pen_type: Option<u8>,
96        shape_width: f32,
97        resolved_width: f32,
98    ) -> u32 {
99        // Shape payload carries per-stroke ARGB in observed files.
100        if shape_color_argb != 0 {
101            return shape_color_argb;
102        }
103
104        let Some(pen_type) = pen_type else {
105            return self.default_color_argb;
106        };
107
108        let target_width = if shape_width.is_finite() && shape_width > 0.0 {
109            shape_width
110        } else {
111            resolved_width
112        };
113
114        if let Some(best_match) = self
115            .quick_pen_styles
116            .iter()
117            .filter(|pen| pen.pen_type == pen_type)
118            .min_by(|a, b| {
119                (a.width - target_width)
120                    .abs()
121                    .total_cmp(&(b.width - target_width).abs())
122            })
123        {
124            return best_match.color_argb;
125        }
126
127        self.default_color_argb
128    }
129}
130
131pub struct NoteFile<R: std::io::Read + std::io::Seek> {
132    container: container::Container<R>,
133    note_tree: NoteTree,
134}
135
136impl<R: std::io::Read + std::io::Seek> NoteFile<R> {
137    pub fn read(reader: R) -> Result<Self> {
138        let mut container = container::Container::open(reader)?;
139
140        let note_tree = if *container.container_type() == container::ContainerType::MultiNote {
141            container.get_file_relative("note_tree", |reader| NoteTree::read(reader))?
142        } else {
143            container.get_file_relative("note/pb/note_info", |reader| NoteTree::read(reader))?
144        };
145
146        Ok(Self {
147            container,
148            note_tree,
149        })
150    }
151
152    pub fn list_notes(&self) -> HashMap<NoteUuid, String> {
153        self.note_tree
154            .notes
155            .iter()
156            .map(|(id, metadata)| (*id, metadata.name.clone()))
157            .collect()
158    }
159
160    pub fn get_note(&self, note_id: &NoteUuid) -> Option<Note<R>> {
161        self.note_tree.notes.get(note_id).map(|metadata| {
162            let note_path_prefix =
163                if *self.container.container_type() == container::ContainerType::MultiNote {
164                    metadata.note_id.to_simple_string()
165                } else {
166                    String::new()
167                };
168            Note::new(self.container.clone(), metadata.clone(), note_path_prefix)
169        })
170    }
171}
172
173impl<R: std::io::Read + std::io::Seek> std::fmt::Debug for NoteFile<R> {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        f.debug_struct("NoteFile")
176            .field("container_type", &self.container.container_type())
177            .field("note_tree", &self.note_tree)
178            .finish()
179    }
180}
181
182pub struct Note<R: std::io::Read + std::io::Seek> {
183    container: container::Container<R>,
184    metadata: NoteMetadata,
185    note_path_prefix: String,
186    virtual_doc: Option<VirtualDoc>,
187    virtual_pages: Option<HashMap<VirtualPageUuid, VirtualPage>>,
188    page_models: Option<HashMap<PageModelUuid, PageModelGroup>>,
189    extra_metadata: Option<Option<extra::ExtraMetadata>>,
190    templates: Option<template::TemplateSet>,
191    resources: Option<Vec<resource::ResourceRecord>>,
192    assets_metadata: Option<note_assets::NoteAssetsMetadata>,
193}
194
195impl<R: std::io::Read + std::io::Seek> Note<R> {
196    fn new(
197        container: container::Container<R>,
198        metadata: NoteMetadata,
199        note_path_prefix: String,
200    ) -> Self {
201        Self {
202            container,
203            metadata,
204            note_path_prefix,
205            virtual_doc: None,
206            virtual_pages: None,
207            page_models: None,
208            extra_metadata: None,
209            templates: None,
210            resources: None,
211            assets_metadata: None,
212        }
213    }
214
215    pub fn name(&self) -> &str {
216        &self.metadata.name
217    }
218
219    pub fn active_pages(&self) -> &[PageUuid] {
220        &self.metadata.active_pages
221    }
222
223    pub fn reserved_pages(&self) -> &[PageUuid] {
224        &self.metadata.reserved_pages
225    }
226
227    pub fn detached_pages(&self) -> &[PageUuid] {
228        &self.metadata.detached_pages
229    }
230
231    pub fn created(&self) -> chrono::DateTime<chrono::Utc> {
232        self.metadata.created
233    }
234
235    pub fn modified(&self) -> chrono::DateTime<chrono::Utc> {
236        self.metadata.modified
237    }
238
239    pub fn flag(&self) -> u32 {
240        self.metadata.flag
241    }
242
243    pub fn pen_width(&self) -> f32 {
244        self.metadata.pen_width
245    }
246
247    pub fn scale_factor(&self) -> f32 {
248        self.metadata.scale_factor
249    }
250
251    pub fn fill_color(&self) -> &u32 {
252        &self.metadata.fill_color
253    }
254
255    pub fn pen_type(&self) -> &u32 {
256        &self.metadata.pen_type
257    }
258
259    pub fn pen_settings_fill_color(&self) -> &u32 {
260        &self.metadata.pen_settings.fill_color
261    }
262
263    pub fn pen_settings_graphics_shape_color(&self) -> &u32 {
264        &self.metadata.pen_settings.graphics_shape_color
265    }
266
267    pub fn get_page(&mut self, page_id: &PageUuid) -> Option<Page<R>> {
268        let style_context = PageStyleContext::from_note_metadata(&self.metadata);
269
270        let virtual_page = {
271            let virtual_pages = self
272                .virtual_pages()
273                .inspect_err(|_| {
274                    log::error!("Failed to get virtual pages for page ID: {}", page_id);
275                })
276                .ok()?;
277
278            virtual_pages
279                .values()
280                .find(|vp| &vp.page_id == page_id)
281                .cloned()
282        };
283
284        let page_model = {
285            let page_models = self
286                .page_models()
287                .inspect_err(|_| {
288                    log::error!("Failed to get page models for page ID: {}", page_id);
289                })
290                .ok()?;
291
292            page_models
293                .values()
294                .find_map(|pm| pm.page_models.iter().find(|p| p.page_id == *page_id))?
295                .clone()
296        };
297
298        Some(Page::new(
299            self.container.clone(),
300            self.metadata.note_id,
301            page_id.clone(),
302            self.note_path_prefix.clone(),
303            virtual_page,
304            page_model,
305            style_context,
306        ))
307    }
308
309    pub fn virtual_doc(&mut self) -> Result<&VirtualDoc> {
310        if self.virtual_doc.is_none() {
311            let note_id = self.metadata.note_id.to_simple_string();
312            let virtual_doc_path = join_archive_path(
313                &self.note_path_prefix,
314                &format!("virtual/doc/pb/{}", note_id),
315            );
316            let virtual_doc = self
317                .container
318                .get_file_relative(&virtual_doc_path, |reader| VirtualDoc::read(reader))?;
319            self.virtual_doc = Some(virtual_doc);
320        }
321        Ok(self.virtual_doc.as_ref().unwrap())
322    }
323
324    pub fn virtual_pages(&mut self) -> Result<&HashMap<VirtualPageUuid, VirtualPage>> {
325        if self.virtual_pages.is_none() {
326            let mut virtual_pages = HashMap::new();
327
328            for virtual_page_path in self.container.list_directory(&join_archive_path(
329                &self.note_path_prefix,
330                "virtual/page/pb",
331            )) {
332                let virtual_page_id =
333                    VirtualPageUuid::from_str(file_name_from_path(&virtual_page_path)?)?;
334                let virtual_page = self
335                    .container
336                    .get_file_absolute(&virtual_page_path, |reader| VirtualPage::read(reader))?;
337                virtual_pages.insert(virtual_page_id, virtual_page);
338            }
339            self.virtual_pages = Some(virtual_pages);
340        }
341        Ok(self.virtual_pages.as_ref().unwrap())
342    }
343
344    pub fn page_models(&mut self) -> Result<&HashMap<PageModelUuid, PageModelGroup>> {
345        if self.page_models.is_none() {
346            let mut page_models = HashMap::new();
347
348            for page_model_path in self
349                .container
350                .list_directory(&join_archive_path(&self.note_path_prefix, "pageModel/pb"))
351            {
352                let page_model_id =
353                    PageModelUuid::from_str(file_name_from_path(&page_model_path)?)?;
354                let page_model = self
355                    .container
356                    .get_file_absolute(&page_model_path, |reader| PageModelGroup::read(reader))?;
357                page_models.insert(page_model_id, page_model);
358            }
359            self.page_models = Some(page_models);
360        }
361        Ok(self.page_models.as_ref().unwrap())
362    }
363
364    pub fn extra_metadata(&mut self) -> Result<Option<&extra::ExtraMetadata>> {
365        if self.extra_metadata.is_none() {
366            let extra_path = join_archive_path(&self.note_path_prefix, "extra/pb/extra");
367            let parsed_extra =
368                if self.container.has_entry_relative(&extra_path) {
369                    Some(self.container.get_file_relative(&extra_path, |reader| {
370                        extra::ExtraMetadata::read(reader)
371                    })?)
372                } else {
373                    None
374                };
375            self.extra_metadata = Some(parsed_extra);
376        }
377
378        Ok(self.extra_metadata.as_ref().unwrap().as_ref())
379    }
380
381    pub fn templates(&mut self) -> Result<&template::TemplateSet> {
382        if self.templates.is_none() {
383            let mut templates = template::TemplateSet::new();
384
385            let primary_templates_path = join_archive_path(&self.note_path_prefix, "template/json");
386            self.load_templates_from_directory(&primary_templates_path, &mut templates)?;
387
388            let document_templates_path = join_archive_path(
389                &self.note_path_prefix,
390                &format!(
391                    "document/{}/template/json",
392                    self.metadata.note_id.to_simple_string()
393                ),
394            );
395            self.load_templates_from_directory(&document_templates_path, &mut templates)?;
396
397            self.templates = Some(templates);
398        }
399
400        Ok(self.templates.as_ref().unwrap())
401    }
402
403    pub fn default_template(&mut self) -> Result<Option<&template::TemplateDescriptor>> {
404        Ok(self.templates()?.default.as_ref())
405    }
406
407    pub fn template_for_page(
408        &mut self,
409        page_id: &PageUuid,
410    ) -> Result<Option<&template::TemplateDescriptor>> {
411        Ok(self.templates()?.for_page(page_id))
412    }
413
414    pub fn resources(&mut self) -> Result<&Vec<resource::ResourceRecord>> {
415        if self.resources.is_none() {
416            let mut resources = Vec::new();
417            let resource_path = join_archive_path(&self.note_path_prefix, "resource/pb");
418
419            if self.container.has_entry_relative(&resource_path) {
420                for archive_path in self.container.list_directory(&resource_path) {
421                    let container_relative_path =
422                        to_container_relative_path(self.container.root_path(), &archive_path);
423                    let note_relative_path =
424                        to_note_relative_path(&self.note_path_prefix, &container_relative_path);
425                    let size_bytes = self
426                        .container
427                        .file_size_relative(&container_relative_path)?;
428
429                    let resource = if size_bytes == 0 {
430                        resource::ResourceRecord::read(
431                            note_relative_path,
432                            std::io::Cursor::new(Vec::<u8>::new()),
433                            size_bytes,
434                        )?
435                    } else {
436                        let bytes =
437                            self.container
438                                .get_file_absolute(&archive_path, |mut reader| {
439                                    let mut buffer = Vec::new();
440                                    reader.read_to_end(&mut buffer).map_err(Error::Io)?;
441                                    Ok(buffer)
442                                })?;
443                        resource::ResourceRecord::read(
444                            note_relative_path,
445                            std::io::Cursor::new(bytes),
446                            size_bytes,
447                        )?
448                    };
449                    resources.push(resource);
450                }
451            }
452
453            self.resources = Some(resources);
454        }
455
456        Ok(self.resources.as_ref().unwrap())
457    }
458
459    pub fn assets_metadata(&mut self) -> Result<&note_assets::NoteAssetsMetadata> {
460        if self.assets_metadata.is_none() {
461            let toc_path = join_archive_path(&self.note_path_prefix, "toc");
462            let toc_exists = self.container.has_entry_relative(&toc_path);
463
464            let mut toc_files = Vec::new();
465            for archive_path in self.container.list_directory(&toc_path) {
466                let container_relative_path =
467                    to_container_relative_path(self.container.root_path(), &archive_path);
468                let note_relative_path =
469                    to_note_relative_path(&self.note_path_prefix, &container_relative_path);
470                let size_bytes = self
471                    .container
472                    .file_size_relative(&container_relative_path)?;
473                toc_files.push(note_assets::FileMetadata {
474                    path: note_relative_path,
475                    size_bytes,
476                });
477            }
478
479            let preferred_preview_path = join_archive_path(
480                &self.note_path_prefix,
481                &format!("{}.png", self.metadata.note_id.to_simple_string()),
482            );
483            let preview = if self.container.has_entry_relative(&preferred_preview_path) {
484                let size_bytes = self.container.file_size_relative(&preferred_preview_path)?;
485                Some(note_assets::FileMetadata {
486                    path: to_note_relative_path(&self.note_path_prefix, &preferred_preview_path),
487                    size_bytes,
488                })
489            } else {
490                self.discover_fallback_preview()?
491            };
492
493            self.assets_metadata = Some(note_assets::NoteAssetsMetadata {
494                toc: note_assets::TocMetadata {
495                    exists: toc_exists,
496                    files: toc_files,
497                },
498                preview,
499            });
500        }
501
502        Ok(self.assets_metadata.as_ref().unwrap())
503    }
504
505    fn load_templates_from_directory(
506        &mut self,
507        directory_path: &str,
508        templates: &mut template::TemplateSet,
509    ) -> Result {
510        if !self.container.has_entry_relative(directory_path) {
511            return Ok(());
512        }
513
514        for template_path in self.container.list_directory(directory_path) {
515            let file_name = file_name_from_path(&template_path)?;
516            let Some(key) = template::classify_template_file_name(file_name) else {
517                continue;
518            };
519
520            // Some real-world notes contain zero-byte template_json entries (placeholders
521            // for templates that were never materialised on the device). Skip them rather
522            // than failing the whole note's parse.
523            let bytes = self.container.get_file_absolute(&template_path, |mut reader| {
524                let mut buf = Vec::new();
525                reader.read_to_end(&mut buf).map_err(Error::Io)?;
526                Ok(buf)
527            })?;
528            if bytes.is_empty() {
529                continue;
530            }
531            let descriptor =
532                template::TemplateDescriptor::read(std::io::Cursor::new(bytes))?;
533            templates.insert_if_absent(key, descriptor);
534        }
535
536        Ok(())
537    }
538
539    fn discover_fallback_preview(&mut self) -> Result<Option<note_assets::FileMetadata>> {
540        let note_root_path = self.note_path_prefix.as_str();
541        for archive_path in self.container.list_directory(note_root_path) {
542            let container_relative_path =
543                to_container_relative_path(self.container.root_path(), &archive_path);
544            let note_relative_path =
545                to_note_relative_path(&self.note_path_prefix, &container_relative_path);
546
547            if !note_relative_path.ends_with(".png") || note_relative_path.contains('/') {
548                continue;
549            }
550
551            let size_bytes = self
552                .container
553                .file_size_relative(&container_relative_path)?;
554            return Ok(Some(note_assets::FileMetadata {
555                path: note_relative_path,
556                size_bytes,
557            }));
558        }
559
560        Ok(None)
561    }
562}
563
564impl<R: std::io::Read + std::io::Seek> std::fmt::Debug for Note<R> {
565    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
566        f.debug_struct("Note")
567            .field("metadata", &self.metadata)
568            .finish()
569    }
570}
571
572pub struct Page<R: std::io::Read + std::io::Seek> {
573    container: container::Container<R>,
574    note_id: NoteUuid,
575    note_path_prefix: String,
576    page_id: PageUuid,
577    virtual_page: Option<VirtualPage>,
578    page_model: PageModel,
579    shape_groups: Option<HashMap<ShapeGroupUuid, ShapeGroup>>,
580    shape_group_draw_order: Option<Vec<ShapeGroupUuid>>,
581    points_files: Option<HashMap<PointsUuid, Vec<points::PointsFile>>>,
582    template: Option<Option<template::TemplateDescriptor>>,
583    style_context: PageStyleContext,
584}
585
586impl<R: std::io::Read + std::io::Seek> Page<R> {
587    fn new(
588        container: container::Container<R>,
589        note_id: NoteUuid,
590        page_id: PageUuid,
591        note_path_prefix: String,
592        virtual_page: Option<VirtualPage>,
593        page_model: PageModel,
594        style_context: PageStyleContext,
595    ) -> Self {
596        Self {
597            container,
598            note_id,
599            page_id,
600            note_path_prefix,
601            virtual_page,
602            page_model,
603            shape_groups: None,
604            shape_group_draw_order: None,
605            points_files: None,
606            template: None,
607            style_context,
608        }
609    }
610
611    pub fn virtual_page(&self) -> &Option<VirtualPage> {
612        &self.virtual_page
613    }
614
615    pub fn page_model(&self) -> &PageModel {
616        &self.page_model
617    }
618
619    pub fn template(&mut self) -> Result<Option<&template::TemplateDescriptor>> {
620        if self.template.is_none() {
621            let page_id_simple = self.page_id.to_simple_string();
622            let page_id_hyphenated = self.page_id.to_hyphenated_string();
623            let note_id_simple = self.note_id.to_simple_string();
624
625            let template_paths = [
626                join_archive_path(
627                    &self.note_path_prefix,
628                    &format!("template/json/{}.template_json", page_id_simple),
629                ),
630                join_archive_path(
631                    &self.note_path_prefix,
632                    &format!("template/json/{}.template_json", page_id_hyphenated),
633                ),
634                join_archive_path(
635                    &self.note_path_prefix,
636                    &format!(
637                        "document/{}/template/json/{}.template_json",
638                        note_id_simple, page_id_simple
639                    ),
640                ),
641                join_archive_path(
642                    &self.note_path_prefix,
643                    &format!(
644                        "document/{}/template/json/{}.template_json",
645                        note_id_simple, page_id_hyphenated
646                    ),
647                ),
648            ];
649
650            let mut matched_template = None;
651            for template_path in template_paths {
652                if !self.container.has_entry_relative(&template_path) {
653                    continue;
654                }
655
656                // Skip zero-byte template placeholders (see load_templates_from_directory).
657                let bytes = self.container.get_file_relative(&template_path, |mut reader| {
658                    let mut buf = Vec::new();
659                    reader.read_to_end(&mut buf).map_err(Error::Io)?;
660                    Ok(buf)
661                })?;
662                if bytes.is_empty() {
663                    continue;
664                }
665                matched_template =
666                    Some(template::TemplateDescriptor::read(std::io::Cursor::new(bytes))?);
667                break;
668            }
669
670            self.template = Some(matched_template);
671        }
672
673        Ok(self.template.as_ref().unwrap().as_ref())
674    }
675
676    pub fn shape_groups(&mut self) -> Result<&HashMap<ShapeGroupUuid, ShapeGroup>> {
677        if self.shape_groups.is_none() {
678            // Boox stores shape files keyed by either the simple or hyphenated page UUID,
679            // sometimes mixing both forms within the same note. Try both prefixes.
680            let page_id_simple = self.page_id.to_simple_string();
681            let page_id_hyphenated = self.page_id.to_hyphenated_string();
682
683            let mut shape_groups = HashMap::new();
684            let mut shape_group_entries = Vec::new();
685
686            let shape_prefixes = [
687                join_archive_path(&self.note_path_prefix, &format!("shape/{}#", page_id_simple)),
688                join_archive_path(
689                    &self.note_path_prefix,
690                    &format!("shape/{}#", page_id_hyphenated),
691                ),
692            ];
693            let shape_paths: Vec<String> = shape_prefixes
694                .iter()
695                .flat_map(|prefix| self.container.list_directory(prefix))
696                .collect();
697
698            for (index, shape_group_path) in shape_paths.into_iter().enumerate() {
699                let path_tail = file_name_from_path(&shape_group_path)?;
700                let (shape_group_id, timestamp) = parse_shape_group_file_name(path_tail)?;
701                let _timestamp = convert_timestamp_to_datetime(timestamp)?;
702                let shape_group = self
703                    .container
704                    .get_file_absolute(&shape_group_path, |reader| ShapeGroup::read(reader))?;
705
706                if !shape_groups.contains_key(&shape_group_id) {
707                    shape_group_entries.push((timestamp, index, shape_group_id));
708                }
709                shape_groups.insert(shape_group_id, shape_group);
710            }
711            shape_group_entries.sort_by_key(|(timestamp, index, _)| (*timestamp, *index));
712            self.shape_groups = Some(shape_groups);
713            self.shape_group_draw_order = Some(
714                shape_group_entries
715                    .into_iter()
716                    .map(|(_, _, shape_group_id)| shape_group_id)
717                    .collect(),
718            );
719        }
720        Ok(self.shape_groups.as_ref().unwrap())
721    }
722
723    pub fn points_files(&mut self) -> Result<&HashMap<PointsUuid, Vec<points::PointsFile>>> {
724        if self.points_files.is_none() {
725            // Mirror the simple/hyphenated page-id duality used by the shape directory.
726            let page_id_simple = self.page_id.to_simple_string();
727            let page_id_hyphenated = self.page_id.to_hyphenated_string();
728
729            let mut points_files = HashMap::new();
730
731            let points_prefixes = [
732                join_archive_path(
733                    &self.note_path_prefix,
734                    &format!("point/{}/{}#", page_id_simple, page_id_simple),
735                ),
736                join_archive_path(
737                    &self.note_path_prefix,
738                    &format!("point/{}/{}#", page_id_hyphenated, page_id_hyphenated),
739                ),
740            ];
741            let stroke_paths: Vec<String> = points_prefixes
742                .iter()
743                .flat_map(|prefix| self.container.list_directory(prefix))
744                .collect();
745
746            for stroke_path in stroke_paths {
747                let path_tail = file_name_from_path(&stroke_path)?;
748                let shape_id = parse_points_file_name(path_tail)?;
749
750                let file_data = self
751                    .container
752                    .get_file_absolute(&stroke_path, |mut reader| {
753                        let mut buffer = Vec::new();
754                        reader.read_to_end(&mut buffer).map_err(Error::Io)?;
755                        Ok(buffer)
756                    })?;
757
758                let buffer_cursor = std::io::Cursor::new(file_data);
759                let points_file = points::PointsFile::read(buffer_cursor)?;
760
761                points_files
762                    .entry(shape_id)
763                    .or_insert_with(Vec::new)
764                    .push(points_file);
765            }
766            self.points_files = Some(points_files);
767        }
768        Ok(self.points_files.as_ref().unwrap())
769    }
770
771    /// Returns `true` when the page has no renderable strokes — either no shape
772    /// groups exist for it, or every shape group is empty. Lazily loads shape
773    /// group metadata if it has not been read yet.
774    pub fn is_empty(&mut self) -> Result<bool> {
775        self.shape_groups()?;
776        let shape_groups = self.shape_groups.as_ref().unwrap();
777        Ok(shape_groups.values().all(|group| group.shapes().is_empty()))
778    }
779
780    pub fn render(&mut self) -> Result<DrawTarget> {
781        let page_id = self.page_id.to_hyphenated_string();
782        let width = self.page_model.dimensions.right - self.page_model.dimensions.left;
783        let height = self.page_model.dimensions.bottom - self.page_model.dimensions.top;
784        let mut draw_target = DrawTarget::new(width as i32, height as i32);
785        let draw_options = DrawOptions::new();
786
787        draw_target.fill_rect(
788            0.0,
789            0.0,
790            width,
791            height,
792            &Source::Solid(raqote::Color::new(255, 255, 255, 255).into()),
793            &DrawOptions::new(),
794        );
795
796        self.shape_groups()
797            .inspect_err(|_| log::error!("Failed to get shape groups for page ID: {}", page_id))?;
798        self.points_files()
799            .inspect_err(|_| log::error!("Failed to get points files for page ID: {}", page_id))?;
800
801        let shape_groups = self.shape_groups.as_ref().unwrap();
802        let shape_group_draw_order = self.shape_group_draw_order.as_ref().unwrap();
803        let points_files = self.points_files.as_ref().unwrap();
804
805        // Shape group files can contain historical revisions for the same stroke UUID.
806        // Render only the final state (last occurrence in group draw order / file order).
807        let mut last_shape_index_by_stroke = HashMap::new();
808        let mut shape_index = 0usize;
809        for shape_group_id in shape_group_draw_order {
810            let Some(shape_group) = shape_groups.get(shape_group_id) else {
811                continue;
812            };
813            for shape in shape_group.shapes() {
814                last_shape_index_by_stroke.insert(shape.stroke_id, shape_index);
815                shape_index += 1;
816            }
817        }
818
819        let mut layer_draw_order: Vec<u32> = self
820            .page_model
821            .layers
822            .iter()
823            .filter(|layer| layer.show)
824            .map(|layer| layer.id.value())
825            .collect();
826
827        for shape_group_id in shape_group_draw_order {
828            let Some(shape_group) = shape_groups.get(shape_group_id) else {
829                continue;
830            };
831            for shape in shape_group.shapes() {
832                if !layer_draw_order.contains(&shape.unknown_6) {
833                    layer_draw_order.push(shape.unknown_6);
834                }
835            }
836        }
837
838        // Render in layer-list order from page model. On observed pages this preserves
839        // highlighter-vs-ink stacking without hardcoding pen-type semantics. Within a
840        // layer, draw translucent strokes (highlighters/markers) before opaque ones so
841        // markers sit behind ink rather than on top.
842        const ALPHA_PASS_TRANSLUCENT: u8 = 0;
843        const ALPHA_PASS_OPAQUE: u8 = 1;
844        for layer_id in layer_draw_order {
845          for alpha_pass in [ALPHA_PASS_TRANSLUCENT, ALPHA_PASS_OPAQUE] {
846            let mut shape_index = 0usize;
847            for shape_group_id in shape_group_draw_order {
848                let Some(shape_group) = shape_groups.get(shape_group_id) else {
849                    log::warn!(
850                        "Shape group listed in draw order but missing in map: {}",
851                        shape_group_id.to_hyphenated_string()
852                    );
853                    continue;
854                };
855                for shape in shape_group.shapes() {
856                    let is_final_shape_revision =
857                        last_shape_index_by_stroke.get(&shape.stroke_id).copied()
858                            == Some(shape_index);
859                    shape_index += 1;
860
861                    if !is_final_shape_revision {
862                        continue;
863                    }
864
865                    if shape.unknown_6 != layer_id {
866                        continue;
867                    }
868
869                    let pen_type = u8::try_from(shape.z_order).ok();
870                    let stroke_width = self
871                        .style_context
872                        .resolve_stroke_width(shape.stroke_width, pen_type);
873                    let mut stroke_style = StrokeStyle {
874                        width: stroke_width,
875                        cap: LineCap::Round,
876                        join: LineJoin::Round,
877                        ..StrokeStyle::default()
878                    };
879
880                    if let Some(line_style) = shape.line_style.as_ref() {
881                        stroke_style.dash_offset = if line_style.phase.is_finite() {
882                            line_style.phase
883                        } else {
884                            0.0
885                        };
886                        stroke_style.dash_array = match line_style.type_ {
887                            0 => Vec::new(),
888                            1 => vec![4.0 * stroke_width, 2.0 * stroke_width],
889                            2 => vec![stroke_width, 1.5 * stroke_width],
890                            3 => vec![
891                                4.0 * stroke_width,
892                                2.0 * stroke_width,
893                                stroke_width,
894                                2.0 * stroke_width,
895                            ],
896                            _ => Vec::new(),
897                        };
898                    }
899
900                    if let Some(points_id) = shape.points_id {
901                        if let Some(points_candidates) = points_files.get(&points_id) {
902                            let stroke = points_candidates
903                                .iter()
904                                .find_map(|points_file| points_file.get_stroke(&shape.stroke_id))
905                                .ok_or_else(|| {
906                                    log::error!("Failed to get stroke for shape");
907                                    Error::StrokeNotFound
908                                })?;
909
910                            let resolved_argb = self.style_context.resolve_stroke_color(
911                                shape.unknown as u32,
912                                pen_type,
913                                shape.stroke_width,
914                                stroke_width,
915                            );
916                            let stroke_color_argb = apply_pen_type_alpha(resolved_argb, pen_type);
917
918                            let stroke_alpha = (stroke_color_argb >> 24) as u8;
919                            let is_translucent = stroke_alpha < 255;
920                            let pass_matches = match alpha_pass {
921                                ALPHA_PASS_TRANSLUCENT => is_translucent,
922                                _ => !is_translucent,
923                            };
924                            if !pass_matches {
925                                continue;
926                            }
927
928                            log::debug!("Rendering stroke for shape");
929                            log::debug!(
930                                "Shape Group ID: {}, Stroke ID: {}",
931                                shape_group_id.to_hyphenated_string(),
932                                shape.stroke_id.to_hyphenated_string()
933                            );
934                            log::debug!("Shape: {:#x?}", shape);
935
936                            stroke.render_with_color(
937                                &mut draw_target,
938                                &draw_options,
939                                &stroke_style,
940                                argb_to_raqote_color(stroke_color_argb),
941                            )?;
942                        } else {
943                            log::warn!(
944                                "No points files found for shape group: {}",
945                                shape_group_id.to_hyphenated_string()
946                            );
947                        }
948                    }
949                }
950            }
951          }
952        }
953
954        Ok(draw_target)
955    }
956}
957
958fn argb_to_raqote_color(color_argb: u32) -> raqote::Color {
959    let [a, r, g, b] = color_argb.to_be_bytes();
960    raqote::Color::new(a, r, g, b)
961}
962
963/// Pen type observed in NoteAir4C data for the highlighter ("marker") tool. Strokes of
964/// this type are stored with fully opaque ARGB on disk, but the device renders them
965/// translucently so ink underneath shows through.
966const PEN_TYPE_HIGHLIGHTER: u8 = 15;
967
968/// Highlighter rendering alpha. 0x80 ≈ 50% opacity matches the Boox marker tool's
969/// observed on-device appearance closely enough that ink remains legible underneath.
970const HIGHLIGHTER_ALPHA: u8 = 0x80;
971
972/// Apply firmware-side pen-type semantics that aren't captured in the per-stroke ARGB.
973/// Currently this only adjusts the highlighter's alpha; other pen types are unchanged.
974fn apply_pen_type_alpha(color_argb: u32, pen_type: Option<u8>) -> u32 {
975    if pen_type == Some(PEN_TYPE_HIGHLIGHTER) {
976        (color_argb & 0x00FF_FFFF) | ((HIGHLIGHTER_ALPHA as u32) << 24)
977    } else {
978        color_argb
979    }
980}
981
982fn join_archive_path(prefix: &str, tail: &str) -> String {
983    if prefix.is_empty() {
984        tail.to_string()
985    } else {
986        format!("{}/{}", prefix, tail)
987    }
988}
989
990fn file_name_from_path(path: &str) -> Result<&str> {
991    path.rsplit('/')
992        .next()
993        .filter(|name| !name.is_empty())
994        .ok_or_else(|| Error::InvalidArchiveEntryName(path.to_string()))
995}
996
997fn to_container_relative_path(root_path: &str, archive_path: &str) -> String {
998    if root_path.is_empty() {
999        return archive_path.trim_start_matches('/').to_string();
1000    }
1001
1002    archive_path
1003        .trim_start_matches('/')
1004        .strip_prefix(&format!("{}/", root_path))
1005        .unwrap_or_else(|| archive_path.trim_start_matches('/'))
1006        .to_string()
1007}
1008
1009fn to_note_relative_path(note_path_prefix: &str, container_relative_path: &str) -> String {
1010    if note_path_prefix.is_empty() {
1011        return container_relative_path.to_string();
1012    }
1013
1014    container_relative_path
1015        .strip_prefix(&format!("{}/", note_path_prefix))
1016        .unwrap_or(container_relative_path)
1017        .to_string()
1018}
1019
1020fn parse_shape_group_file_name(file_name: &str) -> Result<(ShapeGroupUuid, u64)> {
1021    let mut segments = file_name.split('#');
1022    let _page_id = segments.next();
1023    let shape_group_uuid = segments.next();
1024    let timestamp_with_suffix = segments.next();
1025    let extra = segments.next();
1026
1027    if shape_group_uuid.is_none() || timestamp_with_suffix.is_none() || extra.is_some() {
1028        return Err(Error::InvalidArchiveEntryName(file_name.to_string()));
1029    }
1030
1031    let shape_group_id = ShapeGroupUuid::from_str(shape_group_uuid.unwrap())?;
1032
1033    let timestamp_with_suffix = timestamp_with_suffix.unwrap();
1034    let timestamp_str = timestamp_with_suffix
1035        .strip_suffix(".zip")
1036        .ok_or_else(|| Error::InvalidArchiveEntryName(file_name.to_string()))?;
1037    let timestamp = timestamp_str.parse::<u64>().map_err(|e| {
1038        Error::InvalidTimestampFormat(format!(
1039            "Failed to parse timestamp in '{}': {}",
1040            file_name, e
1041        ))
1042    })?;
1043
1044    Ok((shape_group_id, timestamp))
1045}
1046
1047fn parse_points_file_name(file_name: &str) -> Result<PointsUuid> {
1048    let mut segments = file_name.split('#');
1049    let _page_id = segments.next();
1050    let points_uuid = segments.next();
1051    let marker = segments.next();
1052    let extra = segments.next();
1053
1054    if points_uuid.is_none() || marker != Some("points") || extra.is_some() {
1055        return Err(Error::InvalidArchiveEntryName(file_name.to_string()));
1056    }
1057
1058    PointsUuid::from_str(points_uuid.unwrap())
1059}
1060
1061impl<R: std::io::Read + std::io::Seek> std::fmt::Debug for Page<R> {
1062    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1063        f.debug_struct("Page")
1064            .field("virtual_page", &self.virtual_page)
1065            .field("page_model", &self.page_model)
1066            .finish()
1067    }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use crate::{
1073        id::{PointsUuid, ShapeGroupUuid},
1074        parse_points_file_name, parse_shape_group_file_name,
1075    };
1076
1077    #[test]
1078    fn parses_shape_group_filename() {
1079        let file_name = "ba338e220eda49268c7126a02970a160#537164a1-9052-496a-80d3-a3aadcff339b#1753456222637.zip";
1080        let (shape_group_uuid, timestamp) = parse_shape_group_file_name(file_name).unwrap();
1081
1082        assert_eq!(
1083            shape_group_uuid,
1084            ShapeGroupUuid::from_str("537164a1-9052-496a-80d3-a3aadcff339b").unwrap()
1085        );
1086        assert_eq!(timestamp, 1753456222637);
1087    }
1088
1089    #[test]
1090    fn rejects_invalid_shape_group_filename() {
1091        let file_name =
1092            "ba338e220eda49268c7126a02970a160#537164a1-9052-496a-80d3-a3aadcff339b#1753456222637";
1093        assert!(parse_shape_group_file_name(file_name).is_err());
1094    }
1095
1096    #[test]
1097    fn parses_points_filename() {
1098        let file_name =
1099            "ba338e220eda49268c7126a02970a160#e858c829-d2f3-4994-b9fb-95bcc8003fa3#points";
1100        let points_uuid = parse_points_file_name(file_name).unwrap();
1101
1102        assert_eq!(
1103            points_uuid,
1104            PointsUuid::from_str("e858c829-d2f3-4994-b9fb-95bcc8003fa3").unwrap()
1105        );
1106    }
1107
1108    #[test]
1109    fn rejects_invalid_points_filename() {
1110        let file_name = "ba338e220eda49268c7126a02970a160#e858c829-d2f3-4994-b9fb-95bcc8003fa3";
1111        assert!(parse_points_file_name(file_name).is_err());
1112    }
1113}