Skip to main content

jbig2enc_rust/jbig2enc/
mod.rs

1//! This module contains the main JBIG2 encoder logic.
2mod symbol;
3
4#[allow(unused_imports)]
5use symbol::dictionary::{encode_symbol_dictionary_segments, plan_symbol_dictionary_layout};
6#[allow(unused_imports)]
7use symbol::text_region::{
8    compute_symbol_hash, log2up, symbol_id_from_dense_maps, uf_find, uf_union,
9};
10#[allow(unused_imports)]
11use symbol::types::*;
12
13// Re-exports preserving the original `crate::jbig2enc::*` public paths after
14// the symbol-dictionary/text-region machinery moved into `symbol::*` submodules.
15pub use symbol::dictionary::{
16    TextRegionSymbolInstance, build_dictionary_and_get_instances, canonicalize_dict_symbols,
17    encode_symbol_dict, encode_symbol_dict_with_order,
18};
19pub use symbol::extraction::segment_symbols;
20pub use symbol::text_region::{
21    encode_page_with_symbol_dictionary, encode_text_region, encode_text_region_mapped,
22};
23pub use symbol::text_region_refine::encode_text_region_with_refinement;
24pub use symbol::types::SymbolInstance;
25
26use crate::jbig2arith::Jbig2ArithCoder;
27use crate::jbig2classify::{
28    SymbolSignature, family_bucket_key_for_symbol, family_bucket_neighbors,
29};
30use crate::jbig2comparator::Comparator;
31// Symbol extraction using CC analysis
32#[cfg(feature = "symboldict")]
33use crate::jbig2cc::analyze_page;
34use crate::jbig2structs::{
35    FileHeader, GenericRegionParams, Jbig2Config, LossySymbolMode, PageInfo, Segment, SegmentType,
36};
37
38use crate::jbig2sym::{BitImage, Rect};
39use anyhow::{Result, anyhow};
40
41// Define debug and trace macros at the crate root
42#[macro_export]
43macro_rules! debug {
44    ($($arg:tt)*) => {
45        #[cfg(feature = "trace_encoder")]
46        log::debug!($($arg)*);
47
48        #[cfg(not(feature = "trace_encoder"))]
49        let _ = format_args!($($arg)*);
50    };
51}
52
53#[macro_export]
54macro_rules! trace {
55    ($($arg:tt)*) => {
56        #[cfg(feature = "trace_encoder")]
57        log::trace!($($arg)*);
58
59        #[cfg(not(feature = "trace_encoder"))]
60        let _ = format_args!($($arg)*);
61    };
62}
63
64// Import the macros for use in this module
65#[allow(unused_imports)]
66use crate::{debug, trace};
67
68use ndarray::Array2;
69use rustc_hash::{FxHashMap, FxHashSet};
70use std::hash::{Hash, Hasher};
71use std::time::{Duration, Instant};
72
73/// A key type for hashing bitmaps efficiently
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct HashKey(u64);
76
77impl Hash for HashKey {
78    fn hash<H: Hasher>(&self, state: &mut H) {
79        self.0.hash(state);
80    }
81}
82
83impl std::fmt::Display for HashKey {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        write!(f, "HashKey({:x})", self.0)
86    }
87}
88
89const RECENT_SYMBOL_CACHE_CAP: usize = 64;
90const SYM_UNIFY_EXACT_ANCHOR_BUDGET: usize = 32;
91const SYM_UNIFY_NEIGHBOR_ANCHOR_BUDGET: usize = 16;
92const SYM_UNIFY_STRONG_ANCHOR_MIN_USAGE: usize = 8;
93const SYM_UNIFY_STRONG_ANCHOR_MIN_PAGE_SPAN: usize = 4;
94
95// Jbig2EncConfig has been removed. Use jbig2structs::Jbig2Config directly.
96
97#[derive(Clone)]
98pub struct PageData {
99    pub image: BitImage,
100    pub symbol_instances: Vec<SymbolInstance>,
101}
102
103#[derive(Debug, Clone, Default)]
104pub struct SymbolModeStageMetrics {
105    pub cc_extraction: Duration,
106    pub matching_dedup: Duration,
107    pub clustering: Duration,
108    pub planning: Duration,
109    pub symbol_dict_encoding: Duration,
110    pub text_region_encoding: Duration,
111    pub generic_region_encoding: Duration,
112}
113
114#[derive(Debug, Clone, Default)]
115pub struct SymbolModeStats {
116    pub symbols_discovered: usize,
117    pub symbols_exported: usize,
118    pub avg_symbol_reuse: f64,
119    pub global_symbol_count: usize,
120    pub local_symbol_count: usize,
121    pub comparator_calls: usize,
122    pub comparator_hits: usize,
123    pub exact_hits: usize,
124    pub refined_hits: usize,
125    pub signature_rejects: usize,
126}
127
128#[derive(Debug, Clone, Default)]
129pub struct EncoderMetrics {
130    pub symbol_mode: SymbolModeStageMetrics,
131    pub symbol_stats: SymbolModeStats,
132}
133
134#[derive(Debug, Clone)]
135pub struct PdfSplitOutput {
136    pub global_segments: Option<Vec<u8>>,
137    pub page_streams: Vec<Vec<u8>>,
138    pub local_dict_bytes_per_page: Vec<usize>,
139    pub text_region_bytes_per_page: Vec<usize>,
140    pub generic_region_bytes_per_page: Vec<usize>,
141}
142
143/// Mutable state for the encoder that can change during encoding.
144#[derive(Debug, Default)]
145struct EncoderState {
146    pdf_mode: bool,
147    full_headers_remaining: bool,
148    segment: bool,
149    use_refinement: bool,
150    use_delta_encoding: bool,
151    lossy_symbol_mode_applied: bool,
152    ingest_debug_lines: Vec<String>,
153    decision_debug_lines: Vec<String>,
154}
155
156/// Main JBIG2 encoder that handles document encoding
157///
158/// This struct manages the encoding state and configuration for JBIG2 documents.
159/// It supports both symbol-based and generic region encoding strategies.
160pub struct Jbig2Encoder<'a> {
161    /// Configuration for the encoder
162    config: &'a Jbig2Config,
163
164    /// Internal encoder state
165    state: EncoderState,
166
167    /// Global symbols (shared across pages)
168    global_symbols: Vec<BitImage>,
169
170    /// Usage count for each global symbol
171    symbol_usage: Vec<usize>,
172
173    /// Black pixel count cache for each global symbol (for fast pre-filtering)
174    symbol_pixel_counts: Vec<usize>,
175
176    /// Cheap structural signatures used to reject bad matches before full comparison
177    symbol_signatures: Vec<SymbolSignature>,
178
179    /// Number of distinct pages where each symbol appears
180    symbol_page_count: Vec<usize>,
181
182    /// Last page where the symbol was seen, used to deduplicate per-page membership updates
183    symbol_last_page_seen: Vec<Option<usize>>,
184
185    /// Hash map for quick symbol lookup
186    hash_map: FxHashMap<HashKey, Vec<usize>>,
187
188    /// Page data for each page in the document
189    pages: Vec<PageData>,
190
191    /// Per-page unique symbol indices, built incrementally during extraction
192    page_symbol_indices: Vec<Vec<usize>>,
193
194    /// Next available segment number
195    next_segment_number: u32,
196
197    /// Segment numbers of the global dictionary segments, in text-region symbol-ID order.
198    global_dict_segment_numbers: Vec<u32>,
199
200    /// Encoder metrics used by the benchmark harness
201    metrics: EncoderMetrics,
202}
203
204impl<'a> Jbig2Encoder<'a> {
205    /// Creates a new JBIG2 encoder with the specified configuration
206    ///
207    /// # Arguments
208    /// * `config` - Configuration for the encoder
209    pub fn new(config: &'a Jbig2Config) -> Self {
210        if config.refine && !config.symbol_mode {
211            panic!("Refinement requires symbol mode to be enabled.");
212        }
213
214        Self {
215            config,
216            state: EncoderState {
217                pdf_mode: false, // start in raw mode
218                full_headers_remaining: config.want_full_headers,
219                segment: true,                 // Default to using segments
220                use_refinement: config.refine, // Enable refinement based on config
221                use_delta_encoding: true,      // Default to using delta encoding
222                lossy_symbol_mode_applied: false,
223                ingest_debug_lines: Vec::new(),
224                decision_debug_lines: Vec::new(),
225            },
226            global_symbols: Vec::new(),
227            symbol_usage: Vec::new(),
228            symbol_pixel_counts: Vec::new(),
229            symbol_signatures: Vec::new(),
230            symbol_page_count: Vec::new(),
231            symbol_last_page_seen: Vec::new(),
232            hash_map: FxHashMap::default(),
233            pages: Vec::new(),
234            page_symbol_indices: Vec::new(),
235            next_segment_number: 1,
236            global_dict_segment_numbers: Vec::new(),
237            metrics: EncoderMetrics::default(),
238        }
239    }
240
241    pub fn dict_only(mut self) -> Self {
242        self.state.full_headers_remaining = false;
243        self.state.pdf_mode = true;
244        self
245    }
246
247    /// Returns the number of pages currently added to the encoder
248    pub fn get_page_count(&self) -> usize {
249        self.pages.len()
250    }
251
252    pub fn metrics_snapshot(&self) -> EncoderMetrics {
253        self.metrics.clone()
254    }
255
256    pub fn decision_debug_log(&self) -> String {
257        if self.state.ingest_debug_lines.is_empty() {
258            return self.state.decision_debug_lines.join("\n");
259        }
260        if self.state.decision_debug_lines.is_empty() {
261            return self.state.ingest_debug_lines.join("\n");
262        }
263
264        let mut out = String::new();
265        out.push_str(&self.state.ingest_debug_lines.join("\n"));
266        out.push('\n');
267        out.push_str(&self.state.decision_debug_lines.join("\n"));
268        out
269    }
270
271    /// Returns debug information about symbol usage
272    pub fn get_symbol_stats(&self) -> String {
273        let total_symbols = self.global_symbols.len();
274        let avg_usage = if total_symbols > 0 {
275            self.symbol_usage.iter().sum::<usize>() as f32 / total_symbols as f32
276        } else {
277            0.0
278        };
279        let low_usage_count = self.symbol_usage.iter().filter(|&&u| u < 2).count();
280
281        format!(
282            "Total symbols: {}, Average usage: {:.1}, Low usage (<2): {}",
283            total_symbols, avg_usage, low_usage_count
284        )
285    }
286
287    pub fn add_page(&mut self, image: &Array2<u8>) -> Result<()> {
288        let bitimage = crate::jbig2sym::array_to_bitimage(image);
289        self.add_page_bitimage(bitimage)
290    }
291
292    // `add_page_bitimage` (~546 lines) is the per-page symbol-extraction and
293    // matching loop. Its body threads a dozen mutable locals — `comparator`,
294    // `debug_lines`, `cc_index`, `symbol_instances`, `instance_bitmap`,
295    // `recent_cache`, `sym_unify_anchor_map`, and the `sym_unify_*` counters —
296    // through three nested match searches (recent-cache, anchor, hash-bucket)
297    // that share early-exit control flow (`matched`, labelled `break`s).
298    // Splitting it into helper methods would mean passing all of that state
299    // across function boundaries via a dozen `&mut` parameters or a new
300    // carrier struct, for a bit-exact encoder where a subtle slip would only
301    // surface as silently different output bytes. That risk is not worth the
302    // line-count savings here — this is the second documented exception to
303    // the ~1000-line target called out in REFACTOR_JBIG2ENC.md (alongside
304    // `plan_document` in `symbol/planning.rs`). It must also stay in `mod.rs`
305    // as a `pub fn`: external integration test crates call it directly on
306    // `Jbig2Encoder`, and `mod symbol` is a private module, so relocating it
307    // there would make it externally unreachable.
308    pub fn add_page_bitimage(&mut self, bitimage: BitImage) -> Result<()> {
309        let page_num = self.pages.len();
310        self.page_symbol_indices.push(Vec::new());
311        let mut symbol_instances = Vec::new();
312        let mut comparator = Comparator::default();
313        let debug_matching =
314            page_num == 0 && std::env::var("JBIG2_DEBUG").map_or(false, |v| v == "1");
315        let no_reuse = std::env::var("JBIG2_NO_REUSE").map_or(false, |v| v == "1");
316
317        let mut debug_lines: Vec<String> = Vec::new();
318        if debug_matching {
319            debug_lines.push("=== PAGE 0 MATCHING LOG ===".to_string());
320            debug_lines.push(format!("Image: {}x{}", bitimage.width, bitimage.height));
321        }
322        let mut cc_index = 0usize;
323        let mut sym_unify_anchor_map = (self.config.lossy_symbol_mode
324            == LossySymbolMode::SymbolUnify
325            && !self.global_symbols.is_empty())
326        .then(|| self.build_sym_unify_anchor_map(page_num));
327        let sym_unify_initial_anchor_count = sym_unify_anchor_map
328            .as_ref()
329            .map(|anchors| anchors.values().map(Vec::len).sum::<usize>())
330            .unwrap_or(0);
331        let sym_unify_initial_anchor_bytes = sym_unify_anchor_map
332            .as_ref()
333            .map(|anchors| anchor_map_dictionary_bytes(&self.global_symbols, anchors))
334            .unwrap_or(0);
335        let mut sym_unify_recent_hits = 0usize;
336        let mut sym_unify_anchor_hits = 0usize;
337        let mut sym_unify_bucket_hits = 0usize;
338        let mut sym_unify_new_symbols = 0usize;
339        let mut sym_unify_anchor_score_rejects = 0usize;
340        let mut sym_unify_anchor_outside_rejects = 0usize;
341        let mut sym_unify_anchor_compare_rejects = 0usize;
342        let mut sym_unify_anchor_overlap_rejects = 0usize;
343
344        // Extract symbols if symbol mode is enabled
345        if self.config.symbol_mode && self.state.segment {
346            #[cfg(feature = "symboldict")]
347            {
348                let dpi = 300; // Default DPI
349                let losslevel =
350                    if self.config.symbol_mode || self.config.uses_lossy_symbol_dictionary() {
351                        0
352                    } else if self.config.is_lossless {
353                        0
354                    } else {
355                        1
356                    };
357                let cc_start = Instant::now();
358                let cc_image = analyze_page(&bitimage, dpi, losslevel);
359                let extracted = cc_image.extract_shape_refs();
360                self.metrics.symbol_mode.cc_extraction += cc_start.elapsed();
361
362                // Check if symbol extraction makes sense for this image
363                // If we only get one symbol that covers the entire image,
364                // it's better to use generic region encoding
365                let should_use_symbols = if extracted.len() == 1 {
366                    let bbox = extracted[0].bbox;
367                    !(bbox.xmin == 0
368                        && bbox.ymin == 0
369                        && bbox.width() as usize >= bitimage.width.saturating_sub(2)
370                        && bbox.height() as usize >= bitimage.height.saturating_sub(2))
371                } else {
372                    !extracted.is_empty()
373                };
374
375                if should_use_symbols {
376                    let matching_start = Instant::now();
377                    let mut recent_cache = RecentSymbolCache::new(RECENT_SYMBOL_CACHE_CAP);
378                    let mut recent_candidates = [0usize; RECENT_SYMBOL_CACHE_CAP];
379                    let mut last_y = 0u32;
380
381                    for shape in extracted {
382                        if Self::should_skip_symbol_candidate(
383                            shape.bbox.width().max(0) as usize,
384                            shape.bbox.height().max(0) as usize,
385                            shape.black_pixels,
386                        ) || shape.run_count == 0
387                        {
388                            continue;
389                        }
390                        let Some(symbol) = cc_image.get_bitmap_for_cc(shape.ccid) else {
391                            continue;
392                        };
393                        let (trim_offset, trimmed) = symbol.trim();
394                        let pixel_count = trimmed.count_ones();
395                        if Self::should_skip_symbol_candidate(
396                            trimmed.width,
397                            trimmed.height,
398                            pixel_count,
399                        ) {
400                            continue;
401                        }
402
403                        // The CC bbox is the bounding box from CC analysis.
404                        // trim() may remove whitespace rows/cols from the symbol.
405                        // Adjust position by trim offset so the dictionary bitmap
406                        // renders at the correct location on the page.
407                        let rect = Rect {
408                            x: shape.bbox.xmin as u32 + trim_offset.x,
409                            y: shape.bbox.ymin as u32 + trim_offset.y,
410                            width: trimmed.width as u32,
411                            height: trimmed.height as u32,
412                        };
413                        if rect.y > last_y.saturating_add(24) {
414                            recent_cache.clear();
415                        }
416                        last_y = rect.y;
417
418                        let trimmed_sig = Self::compute_symbol_signature(&trimmed);
419                        let mut matched = false;
420                        let mut instance_bitmap = Some(symbol);
421
422                        // Error tolerance for matching.
423                        let area = (trimmed.width * trimmed.height) as u32;
424                        let max_err = if self.config.text_refine {
425                            (area / self.config.match_tolerance).max(3)
426                        } else {
427                            ((area as f32 * 0.05) as u32).max(2)
428                        };
429
430                        if !matched && !no_reuse {
431                            let recent_len = recent_cache.copy_into(&mut recent_candidates);
432                            'recent_search: for &idx in &recent_candidates[..recent_len] {
433                                if let Some((err, dx, dy, needs_refinement)) = self
434                                    .evaluate_symbol_match(
435                                        &trimmed,
436                                        trimmed_sig,
437                                        pixel_count,
438                                        idx,
439                                        &mut comparator,
440                                        max_err,
441                                    )
442                                {
443                                    if debug_matching {
444                                        let mode = if needs_refinement {
445                                            "REFINE"
446                                        } else if err == 0 && dx == 0 && dy == 0 {
447                                            "EXACT "
448                                        } else {
449                                            "LOSSY "
450                                        };
451                                        let proto = &self.global_symbols[idx];
452                                        debug_lines.push(format!(
453                                            "CC#{:04} {} pos=({},{}) {}x{} → proto#{} {}x{} err={} dx={} dy={} [recent]",
454                                            cc_index,
455                                            mode,
456                                            rect.x,
457                                            rect.y,
458                                            rect.width,
459                                            rect.height,
460                                            idx,
461                                            proto.width,
462                                            proto.height,
463                                            err,
464                                            dx,
465                                            dy
466                                        ));
467                                    }
468
469                                    self.symbol_usage[idx] += 1;
470                                    self.note_symbol_page(idx, page_num);
471                                    symbol_instances.push(SymbolInstance {
472                                        symbol_index: idx,
473                                        position: rect,
474                                        instance_bitmap: instance_bitmap.take().unwrap(),
475                                        needs_refinement,
476                                        refinement_dx: if needs_refinement { dx } else { 0 },
477                                        refinement_dy: if needs_refinement { dy } else { 0 },
478                                    });
479                                    recent_cache.touch(idx);
480                                    if self.config.lossy_symbol_mode == LossySymbolMode::SymbolUnify
481                                    {
482                                        sym_unify_recent_hits += 1;
483                                    }
484                                    matched = true;
485                                    break 'recent_search;
486                                }
487                            }
488                        }
489
490                        if !matched
491                            && !no_reuse
492                            && self.config.lossy_symbol_mode == LossySymbolMode::SymbolUnify
493                        {
494                            if let Some(anchor_map) = sym_unify_anchor_map.as_mut() {
495                                let anchor_key =
496                                    family_bucket_key_for_symbol(&trimmed, &trimmed_sig);
497                                let mut visited = FxHashSet::default();
498                                let mut exact_examined = 0usize;
499                                if let Some(bucket) = anchor_map.get(&anchor_key) {
500                                    'anchor_search_exact: for &idx in bucket {
501                                        if exact_examined >= SYM_UNIFY_EXACT_ANCHOR_BUDGET {
502                                            break 'anchor_search_exact;
503                                        }
504                                        exact_examined += 1;
505                                        if !visited.insert(idx) {
506                                            continue;
507                                        }
508                                        let decision = self.evaluate_symbol_unify_anchor_match(
509                                            &trimmed,
510                                            trimmed_sig,
511                                            pixel_count,
512                                            idx,
513                                            &mut comparator,
514                                        );
515                                        let (score, dx, dy) = match decision {
516                                            SymUnifyAnchorDecision::Accept { score, dx, dy } => {
517                                                (score, dx, dy)
518                                            }
519                                            SymUnifyAnchorDecision::RejectScore { .. } => {
520                                                sym_unify_anchor_score_rejects += 1;
521                                                continue;
522                                            }
523                                            SymUnifyAnchorDecision::RejectOutsideInk => {
524                                                sym_unify_anchor_outside_rejects += 1;
525                                                continue;
526                                            }
527                                            SymUnifyAnchorDecision::RejectCompare => {
528                                                sym_unify_anchor_compare_rejects += 1;
529                                                continue;
530                                            }
531                                            SymUnifyAnchorDecision::RejectOverlap => {
532                                                sym_unify_anchor_overlap_rejects += 1;
533                                                continue;
534                                            }
535                                            _ => continue,
536                                        };
537
538                                        if debug_matching {
539                                            let proto = &self.global_symbols[idx];
540                                            debug_lines.push(format!(
541                                                "CC#{:04} UNIFY  pos=({},{}) {}x{} → proto#{} {}x{} score={} dx={} dy={} [anchor]",
542                                                cc_index,
543                                                rect.x,
544                                                rect.y,
545                                                rect.width,
546                                                rect.height,
547                                                idx,
548                                                proto.width,
549                                                proto.height,
550                                                score,
551                                                dx,
552                                                dy
553                                            ));
554                                        }
555
556                                        self.symbol_usage[idx] += 1;
557                                        self.note_symbol_page(idx, page_num);
558                                        self.maybe_add_sym_unify_anchor(anchor_map, idx, page_num);
559                                        symbol_instances.push(SymbolInstance {
560                                            symbol_index: idx,
561                                            position: rect,
562                                            instance_bitmap: instance_bitmap.take().unwrap(),
563                                            needs_refinement: false,
564                                            refinement_dx: 0,
565                                            refinement_dy: 0,
566                                        });
567                                        recent_cache.touch(idx);
568                                        sym_unify_anchor_hits += 1;
569                                        matched = true;
570                                        break;
571                                    }
572                                }
573
574                                if !matched {
575                                    let mut neighbor_examined = 0usize;
576                                    'anchor_search_neighbors: for neighbor in
577                                        family_bucket_neighbors(anchor_key)
578                                    {
579                                        if neighbor == anchor_key {
580                                            continue;
581                                        }
582                                        let Some(bucket) = anchor_map.get(&neighbor) else {
583                                            continue;
584                                        };
585                                        for &idx in bucket {
586                                            if neighbor_examined >= SYM_UNIFY_NEIGHBOR_ANCHOR_BUDGET
587                                            {
588                                                break 'anchor_search_neighbors;
589                                            }
590                                            neighbor_examined += 1;
591                                            if !visited.insert(idx) {
592                                                continue;
593                                            }
594                                            let decision = self.evaluate_symbol_unify_anchor_match(
595                                                &trimmed,
596                                                trimmed_sig,
597                                                pixel_count,
598                                                idx,
599                                                &mut comparator,
600                                            );
601                                            let (score, dx, dy) = match decision {
602                                                SymUnifyAnchorDecision::Accept {
603                                                    score,
604                                                    dx,
605                                                    dy,
606                                                } => (score, dx, dy),
607                                                SymUnifyAnchorDecision::RejectScore { .. } => {
608                                                    sym_unify_anchor_score_rejects += 1;
609                                                    continue;
610                                                }
611                                                SymUnifyAnchorDecision::RejectOutsideInk => {
612                                                    sym_unify_anchor_outside_rejects += 1;
613                                                    continue;
614                                                }
615                                                SymUnifyAnchorDecision::RejectCompare => {
616                                                    sym_unify_anchor_compare_rejects += 1;
617                                                    continue;
618                                                }
619                                                SymUnifyAnchorDecision::RejectOverlap => {
620                                                    sym_unify_anchor_overlap_rejects += 1;
621                                                    continue;
622                                                }
623                                                _ => continue,
624                                            };
625
626                                            if debug_matching {
627                                                let proto = &self.global_symbols[idx];
628                                                debug_lines.push(format!(
629                                                    "CC#{:04} UNIFY  pos=({},{}) {}x{} → proto#{} {}x{} score={} dx={} dy={} [anchor]",
630                                                    cc_index,
631                                                    rect.x,
632                                                    rect.y,
633                                                    rect.width,
634                                                    rect.height,
635                                                    idx,
636                                                    proto.width,
637                                                    proto.height,
638                                                    score,
639                                                    dx,
640                                                    dy
641                                                ));
642                                            }
643
644                                            self.symbol_usage[idx] += 1;
645                                            self.note_symbol_page(idx, page_num);
646                                            self.maybe_add_sym_unify_anchor(
647                                                anchor_map, idx, page_num,
648                                            );
649                                            symbol_instances.push(SymbolInstance {
650                                                symbol_index: idx,
651                                                position: rect,
652                                                instance_bitmap: instance_bitmap.take().unwrap(),
653                                                needs_refinement: false,
654                                                refinement_dx: 0,
655                                                refinement_dy: 0,
656                                            });
657                                            recent_cache.touch(idx);
658                                            sym_unify_anchor_hits += 1;
659                                            matched = true;
660                                            break 'anchor_search_neighbors;
661                                        }
662                                    }
663                                }
664                            }
665                        }
666
667                        if !matched && !no_reuse {
668                            let h = trimmed.height as u64;
669                            let w = trimmed.width as u64;
670                            let dim_range: u64 = if self.config.text_refine { 2 } else { 0 };
671
672                            'bucket_search: for dh_off in 0..=(dim_range * 2) {
673                                let dh = h.wrapping_add(dh_off).wrapping_sub(dim_range);
674                                if dh >= 10_000 {
675                                    continue;
676                                }
677                                for dw_off in 0..=(dim_range * 2) {
678                                    let dw = w.wrapping_add(dw_off).wrapping_sub(dim_range);
679                                    if dw >= 10_000 {
680                                        continue;
681                                    }
682
683                                    let nk = HashKey(dh * 10_000 + dw);
684                                    if let Some(bucket) = self.hash_map.get(&nk) {
685                                        let bucket_len = bucket.len();
686                                        let bucket_ptr = bucket.as_ptr();
687                                        for bucket_pos in 0..bucket_len {
688                                            let idx = unsafe { *bucket_ptr.add(bucket_pos) };
689                                            let Some((err, dx, dy, needs_refinement)) = self
690                                                .evaluate_symbol_match(
691                                                    &trimmed,
692                                                    trimmed_sig,
693                                                    pixel_count,
694                                                    idx,
695                                                    &mut comparator,
696                                                    max_err,
697                                                )
698                                            else {
699                                                continue;
700                                            };
701
702                                            if debug_matching {
703                                                let mode = if needs_refinement {
704                                                    "REFINE"
705                                                } else if err == 0 && dx == 0 && dy == 0 {
706                                                    "EXACT "
707                                                } else {
708                                                    "LOSSY "
709                                                };
710                                                let proto = &self.global_symbols[idx];
711                                                debug_lines.push(format!(
712                                                    "CC#{:04} {} pos=({},{}) {}x{} → proto#{} {}x{} err={} dx={} dy={}",
713                                                    cc_index,
714                                                    mode,
715                                                    rect.x,
716                                                    rect.y,
717                                                    rect.width,
718                                                    rect.height,
719                                                    idx,
720                                                    proto.width,
721                                                    proto.height,
722                                                    err,
723                                                    dx,
724                                                    dy
725                                                ));
726                                            }
727
728                                            self.symbol_usage[idx] += 1;
729                                            self.note_symbol_page(idx, page_num);
730                                            if let Some(anchor_map) = sym_unify_anchor_map.as_mut()
731                                            {
732                                                self.maybe_add_sym_unify_anchor(
733                                                    anchor_map, idx, page_num,
734                                                );
735                                            }
736                                            symbol_instances.push(SymbolInstance {
737                                                symbol_index: idx,
738                                                position: rect,
739                                                instance_bitmap: instance_bitmap.take().unwrap(),
740                                                needs_refinement,
741                                                refinement_dx: if needs_refinement {
742                                                    dx
743                                                } else {
744                                                    0
745                                                },
746                                                refinement_dy: if needs_refinement {
747                                                    dy
748                                                } else {
749                                                    0
750                                                },
751                                            });
752                                            recent_cache.touch(idx);
753                                            if self.config.lossy_symbol_mode
754                                                == LossySymbolMode::SymbolUnify
755                                            {
756                                                sym_unify_bucket_hits += 1;
757                                            }
758                                            matched = true;
759                                            break 'bucket_search;
760                                        }
761                                    }
762                                }
763                            }
764                        }
765
766                        if !matched {
767                            let idx = self.push_symbol(trimmed, pixel_count, page_num);
768                            self.metrics.symbol_stats.symbols_discovered += 1;
769                            if debug_matching {
770                                debug_lines.push(format!(
771                                    "CC#{:04} NEW    pos=({},{}) {}x{} trim_off=({},{}) → new proto#{} {}x{}",
772                                    cc_index, rect.x, rect.y, rect.width, rect.height,
773                                    trim_offset.x, trim_offset.y,
774                                    idx, self.global_symbols[idx].width, self.global_symbols[idx].height
775                                ));
776                            }
777                            let key = hash_key(&self.global_symbols[idx]);
778                            self.hash_map.entry(key).or_default().push(idx);
779                            if let Some(anchor_map) = sym_unify_anchor_map.as_mut() {
780                                self.maybe_add_sym_unify_anchor(anchor_map, idx, page_num);
781                            }
782                            symbol_instances.push(SymbolInstance {
783                                symbol_index: idx,
784                                position: rect,
785                                instance_bitmap: instance_bitmap.take().unwrap(),
786                                needs_refinement: false,
787                                refinement_dx: 0,
788                                refinement_dy: 0,
789                            });
790                            recent_cache.touch(idx);
791                            if self.config.lossy_symbol_mode == LossySymbolMode::SymbolUnify {
792                                sym_unify_new_symbols += 1;
793                            }
794                        }
795                        cc_index += 1;
796                    }
797                    self.metrics.symbol_mode.matching_dedup += matching_start.elapsed();
798                }
799            }
800        }
801
802        // Write page 0 matching debug log
803        if debug_matching && !debug_lines.is_empty() {
804            debug_lines.push(format!(
805                "\nTotal CCs: {}, Instances: {}",
806                cc_index,
807                symbol_instances.len()
808            ));
809            let log_path = std::path::Path::new("jbig2_debug_page0.log");
810            if let Ok(mut f) = std::fs::File::create(log_path) {
811                use std::io::Write;
812                for line in &debug_lines {
813                    let _ = writeln!(f, "{}", line);
814                }
815            }
816        }
817
818        if self.config.lossy_symbol_mode == LossySymbolMode::SymbolUnify
819            && encoder_diagnostics_enabled()
820        {
821            let final_anchor_count = sym_unify_anchor_map
822                .as_ref()
823                .map(|anchors| anchors.values().map(Vec::len).sum::<usize>())
824                .unwrap_or(0);
825            let final_anchor_bytes = sym_unify_anchor_map
826                .as_ref()
827                .map(|anchors| anchor_map_dictionary_bytes(&self.global_symbols, anchors))
828                .unwrap_or(0);
829            self.state.ingest_debug_lines.push(format!(
830                "sym_unify ingest page={}: cc={} recent_hits={} anchor_hits={} bucket_hits={} new_symbols={} initial_anchors={} final_anchors={} initial_anchor_bytes={} final_anchor_bytes={} anchor_score_rejects={} anchor_outside_rejects={} anchor_compare_rejects={} anchor_overlap_rejects={}",
831                page_num + 1,
832                cc_index,
833                sym_unify_recent_hits,
834                sym_unify_anchor_hits,
835                sym_unify_bucket_hits,
836                sym_unify_new_symbols,
837                sym_unify_initial_anchor_count,
838                final_anchor_count,
839                sym_unify_initial_anchor_bytes,
840                final_anchor_bytes,
841                sym_unify_anchor_score_rejects,
842                sym_unify_anchor_outside_rejects,
843                sym_unify_anchor_compare_rejects,
844                sym_unify_anchor_overlap_rejects,
845            ));
846        }
847
848        self.pages.push(PageData {
849            image: bitimage,
850            symbol_instances,
851        });
852        Ok(())
853    }
854
855    pub fn collect_symbols(&mut self, roi: &Array2<u8>) -> Result<()> {
856        let bitimage = crate::jbig2sym::array_to_bitimage(roi);
857        let (_, trimmed) = bitimage.trim();
858        let key = hash_key(&trimmed);
859        let page_num = self.pages.len();
860        if self.page_symbol_indices.len() <= page_num {
861            self.page_symbol_indices.resize_with(page_num + 1, Vec::new);
862        }
863
864        if !self.hash_map.contains_key(&key) {
865            let pixel_count = trimmed.count_ones();
866            let idx = self.push_symbol(trimmed, pixel_count, page_num);
867            self.metrics.symbol_stats.symbols_discovered += 1;
868            self.hash_map.insert(key, vec![idx]);
869        }
870        Ok(())
871    }
872
873    pub fn flush(&mut self) -> Result<Vec<u8>> {
874        let include_header = self.state.full_headers_remaining;
875        self.state.decision_debug_lines.clear();
876        match self.config.lossy_symbol_mode {
877            LossySymbolMode::SymbolUnify => self.apply_symbol_unify()?,
878            LossySymbolMode::Off => {}
879        }
880        let plan = self.plan_document(include_header)?;
881        self.validate_plan(&plan)?;
882        let output = self.serialize_full_document(&plan)?;
883        self.state.full_headers_remaining = false;
884        self.next_segment_number = plan.next_segment_number;
885        Ok(output)
886    }
887
888    pub fn flush_pdf_split(&mut self) -> Result<PdfSplitOutput> {
889        self.state.pdf_mode = true;
890        self.state.decision_debug_lines.clear();
891        match self.config.lossy_symbol_mode {
892            LossySymbolMode::SymbolUnify => self.apply_symbol_unify()?,
893            LossySymbolMode::Off => {}
894        }
895        let plan = self.plan_document(false)?;
896        self.validate_plan(&plan)?;
897        let (
898            global_segments,
899            page_streams,
900            local_dict_bytes_per_page,
901            text_region_bytes_per_page,
902            generic_region_bytes_per_page,
903        ) = self.serialize_pdf_split(&plan)?;
904        self.next_segment_number = plan.next_segment_number;
905        Ok(PdfSplitOutput {
906            global_segments,
907            page_streams,
908            local_dict_bytes_per_page,
909            text_region_bytes_per_page,
910            generic_region_bytes_per_page,
911        })
912    }
913}
914
915/// Encodes a generic region, optionally wrapping it in a complete JBIG2 file.
916/// This function is intended to be the top-level entry point for encoding a single generic region.
917pub fn encode_generic_region(img: &BitImage, cfg: &Jbig2Config) -> Result<Vec<u8>> {
918    // Build generic region config from high-level parameters
919    let mut gr_cfg = GenericRegionParams::new(img.width as u32, img.height as u32, cfg.generic.dpi);
920    gr_cfg.comb_operator = cfg.generic.comb_operator;
921    gr_cfg.mmr = cfg.generic.mmr;
922    gr_cfg.tpgdon = cfg.generic.tpgdon;
923    gr_cfg.validate().map_err(|e: &'static str| anyhow!(e))?;
924
925    let coder_data =
926        Jbig2ArithCoder::encode_generic_payload(img, gr_cfg.template, &gr_cfg.at_pixels)?;
927
928    let params: GenericRegionParams = gr_cfg.clone();
929
930    let mut generic_region_payload = params.to_bytes();
931    generic_region_payload.extend_from_slice(&coder_data);
932
933    // Create the generic region segment (segment number 1)
934    let generic_region_segment = Segment {
935        number: 1, // Segment number 1
936        seg_type: SegmentType::ImmediateGenericRegion,
937        deferred_non_retain: false,
938        retain_flags: 0,
939        page_association_type: 0, // Explicit page association
940        referred_to: Vec::new(),
941        page: Some(1),                           // Page 1
942        payload: generic_region_payload.clone(), // Clone to avoid move
943    };
944
945    // If caller wants only the segment, we're done
946    if !cfg.want_full_headers {
947        let mut seg_bytes = Vec::new();
948        generic_region_segment.write_into(&mut seg_bytes)?;
949        return Ok(seg_bytes);
950    }
951
952    // Otherwise wrap it in a complete one-page JBIG2 file
953    let mut out = Vec::with_capacity(generic_region_payload.len() + 64);
954
955    // File header — sequential organisation (matches segment layout below).
956    out.extend_from_slice(
957        &FileHeader {
958            organisation_type: false,
959            unknown_n_pages: false,
960            n_pages: 1,
961        }
962        .to_bytes(),
963    );
964
965    // Page Information segment (segment number 0)
966    Segment {
967        number: 0,
968        seg_type: SegmentType::PageInformation,
969        deferred_non_retain: false,
970        retain_flags: 0,
971        page_association_type: 0,
972        referred_to: vec![],
973        page: Some(1),
974        payload: PageInfo {
975            width: img.width as u32,
976            height: img.height as u32,
977            xres: cfg.generic.dpi,
978            yres: cfg.generic.dpi,
979            is_lossless: cfg.is_lossless,
980            default_pixel: cfg.default_pixel,
981            default_operator: cfg.generic.comb_operator,
982            ..Default::default()
983        }
984        .to_bytes(),
985    }
986    .write_into(&mut out)?;
987
988    // Generic region segment (segment number 1)
989    generic_region_segment.write_into(&mut out)?;
990
991    // EOF segment (segment number 2)
992    Segment {
993        number: 2,
994        seg_type: SegmentType::EndOfFile,
995        deferred_non_retain: false,
996        retain_flags: 0,
997        page_association_type: 2,
998        referred_to: vec![],
999        page: None,
1000        payload: vec![],
1001    }
1002    .write_into(&mut out)?;
1003
1004    Ok(out)
1005}
1006
1007/// Encodes a sequence of images as a JBIG2 document.
1008///
1009/// # Arguments
1010/// * `images` - A slice of 2D arrays containing the input images
1011/// * `config` - Configuration for the encoder
1012///
1013/// # Returns
1014/// A `Result` containing the encoded JBIG2 document as a byte vector if successful,
1015/// or an error if encoding fails.
1016pub fn encode_document(images: &[Array2<u8>], config: &Jbig2Config) -> Result<Vec<u8>> {
1017    let mut encoder = Jbig2Encoder::new(config);
1018    for image in images {
1019        encoder.add_page(image)?;
1020    }
1021    encoder.flush()
1022}
1023
1024pub fn get_version() -> &'static str {
1025    "0.2.0"
1026}
1027
1028#[inline]
1029pub fn hash_key(img: &BitImage) -> HashKey {
1030    // Dimension-based bucketing: symbols with similar dimensions land in the same
1031    // bucket, enabling fuzzy matching via the Comparator during extraction.
1032    // The Comparator handles size differences up to MAX_DIMENSION_DELTA (10px),
1033    // so we bucket by (height, width) to keep buckets tight.
1034    let h = img.height as u64;
1035    let w = img.width as u64;
1036    HashKey(h * 10_000 + w)
1037}
1038
1039/// Helper function to find the first black pixel in the BitImage
1040/// Returns (x, y) coordinates of the first black pixel, or None if no black pixels
1041pub fn first_black_pixel(image: &BitImage) -> Option<(usize, usize)> {
1042    for y in 0..image.height {
1043        for x in 0..image.width {
1044            if image.get_usize(x, y) {
1045                return Some((x, y));
1046            }
1047        }
1048    }
1049    None
1050}