Skip to main content

jbig2enc_rust/
jbig2cc.rs

1//! Run-length based connected component analysis for JBIG2 encoding.
2//!
3//! This module is a Rust port of the algorithm from DjVuLibre's `cjb2.cpp`,
4//! adapted for JBIG2 symbol dictionary creation. It uses run-length encoding
5//! of scanlines + single-pass union-find to discover connected components.
6//! This approach is dramatically faster and more memory-efficient than
7//! pixel-list algorithms (like Lutz) because:
8//!
9//! 1. **Runs** compress horizontal spans into (y, x1, x2) triples — a typical
10//!    document page might have ~50 000 runs vs millions of pixels.
11//! 2. **Union-find** with path compression gives near-O(n) labeling.
12//! 3. **Reading-order sort** groups components into text lines, which is
13//!    critical for efficient dictionary encoding (similar shapes appear near
14//!    each other).
15//!
16//! ## Integration with JBIG2 Encoder
17//!
18//! This module is enabled with the `symboldict` feature flag. When enabled,
19//! it replaces the Lutz-based pixel-list approach in jbig2lutz.rs:
20//!
21//! ```rust,ignore
22//! #[cfg(feature = "symboldict")]
23//! use crate::jbig2cc::{analyze_page, CCImage};
24//! use crate::jbig2sym::BitImage;
25//!
26//! // 1. Load or create your bilevel page image
27//! let page_image: BitImage = BitImage::new(2550, 3300)?;
28//! // ... fill with scanned document data ...
29//!
30//! // 2. Run connected component analysis
31//! let dpi = 300;        // Image resolution
32//! let losslevel = 1;    // 0 = lossless, >0 = enable cleaning
33//! let cc_image = analyze_page(&page_image, dpi, losslevel);
34//!
35//! // 3. Extract shapes for symbol dictionary
36//! let shapes = cc_image.extract_shapes();
37//!
38//! // 4. Build JBIG2 symbol dictionary from shapes
39//! // (shapes are now (BitImage, BBox) pairs ready for symbol matching)
40//! ```
41//!
42//! ## Feature Flag Usage
43//!
44//! To enable this module, add to your Cargo.toml:
45//! ```toml
46//! [dependencies]
47//! Legencode = { path = "../Legencode", features = ["symboldict"] }
48//! ```
49//!
50//! ## Differences from DjVu JB2 Version
51//!
52//! The only differences from the JB2 version in DJVULibRust are:
53//! 1. Import path: uses `jbig2sym::BitImage` instead of `jb2::symbol_dict::BitImage`
54//! 2. Both BitImage implementations provide the same API: `new()`, `get_pixel_unchecked()`, `set_usize()`
55//! 3. Coordinate systems are identical (top-down y-axis)
56//!
57//! ## DjVuLibre license notice
58//!
59//! The original C++ code is Copyright (c) 2002 Leon Bottou and Yann Le Cun,
60//! distributed under the GNU General Public License v2+.  This Rust port
61//! preserves the algorithmic structure but is a clean-room reimplementation
62//! of the public API and data flow described in the DjVu specification.
63
64use crate::jbig2sym::BitImage;
65
66// ─── Run ────────────────────────────────────────────────────────────────────
67
68/// A horizontal run of foreground (black) pixels on a single scanline.
69#[derive(Clone, Debug)]
70pub struct Run {
71    /// Vertical coordinate (row).  y = 0 is the **top** of the image in our
72    /// coordinate system; cjb2.cpp uses bottom-up, but we canonicalize to
73    /// top-down since `BitImage` is top-down.  The algorithm is symmetric.
74    pub y: i32,
75    /// First (leftmost) horizontal coordinate of the run, inclusive.
76    pub x1: i32,
77    /// Last (rightmost) horizontal coordinate of the run, inclusive.
78    pub x2: i32,
79    /// Connected-component id assigned during analysis.
80    pub ccid: i32,
81}
82
83impl Run {
84    /// Ordering used when sorting: primary by y ascending, secondary by x1.
85    fn sort_key(&self) -> (i32, i32) {
86        (self.y, self.x1)
87    }
88}
89
90// ─── CC descriptor ──────────────────────────────────────────────────────────
91
92/// Bounding box with (xmin, ymin) inclusive and (xmax, ymax) exclusive,
93/// matching DjVuLibre's `GRect` convention.
94#[derive(Clone, Copy, Debug, Default)]
95pub struct BBox {
96    pub xmin: i32,
97    pub ymin: i32,
98    /// Exclusive right edge.
99    pub xmax: i32,
100    /// Exclusive bottom edge.
101    pub ymax: i32,
102}
103
104impl BBox {
105    pub fn width(&self) -> i32 {
106        self.xmax - self.xmin
107    }
108    pub fn height(&self) -> i32 {
109        self.ymax - self.ymin
110    }
111}
112
113/// Descriptor for a single connected component, exactly matching DjVuLibre's `CC`.
114#[derive(Clone, Debug, Default)]
115pub struct CC {
116    /// Bounding box (xmin/ymin inclusive, xmax/ymax exclusive).
117    pub bb: BBox,
118    /// Total number of foreground pixels in this CC.
119    pub npix: i32,
120    /// Number of runs belonging to this CC.
121    pub nrun: i32,
122    /// Index of the first run in the sorted runs array.
123    pub frun: i32,
124}
125
126/// Lightweight handle for a connected component before bitmap materialization.
127#[derive(Clone, Copy, Debug)]
128pub struct ShapeRef {
129    pub ccid: usize,
130    pub bbox: BBox,
131    pub black_pixels: usize,
132    pub run_count: usize,
133}
134
135// ─── CCImage ────────────────────────────────────────────────────────────────
136
137/// An image decomposed into runs, with connected-component analysis,
138/// cleaning, merging/splitting, and reading-order sort — matching the full
139/// pipeline of `cjb2.cpp`'s `CCImage` class.
140pub struct CCImage {
141    pub width: i32,
142    pub height: i32,
143    pub runs: Vec<Run>,
144    pub ccs: Vec<CC>,
145    /// Number of "regular" CCs (text-sized).  CCs at indices ≥ nregularccs
146    /// are "special" (merged small fragments or split large regions).
147    pub nregularccs: usize,
148    /// CCs whose bounding box exceeds this in either dimension get split.
149    pub largesize: i32,
150    /// CCs whose bounding box is ≤ this in both dimensions get merged.
151    pub smallsize: i32,
152    /// CCs with ≤ this many pixels get erased (noise removal).
153    pub tinysize: i32,
154}
155
156impl CCImage {
157    // ── Construction ─────────────────────────────────────────────────────
158
159    /// Create a new empty `CCImage` with DPI-aware thresholds.
160    ///
161    /// The thresholds match cjb2.cpp exactly:
162    /// ```text
163    /// dpi       = clamp(dpi, 200, 900)
164    /// largesize = min(500, max(64, dpi))
165    /// smallsize = max(2, dpi / 150)
166    /// tinysize  = max(0, dpi² / 20000 − 1)
167    /// ```
168    pub fn new(width: i32, height: i32, dpi: i32) -> Self {
169        let dpi = dpi.max(200).min(900);
170        Self {
171            width,
172            height,
173            runs: Vec::new(),
174            ccs: Vec::new(),
175            nregularccs: 0,
176            largesize: 500.min(64.max(dpi)),
177            smallsize: 2.max(dpi / 150),
178            tinysize: 0.max(dpi * dpi / 20000 - 1),
179        }
180    }
181
182    // ── Run extraction ──────────────────────────────────────────────────
183
184    /// Add a single run.
185    pub fn add_single_run(&mut self, y: i32, x1: i32, x2: i32) {
186        self.runs.push(Run { y, x1, x2, ccid: 0 });
187    }
188
189    /// Extract all horizontal runs from a `BitImage`.
190    ///
191    /// This replaces the Lutz pixel-list approach.  For a 2550×3300 page
192    /// at 300 DPI the run list is typically 40–80 k entries, versus tens
193    /// of millions of pixel tuples.
194    pub fn add_bitmap_runs(&mut self, bm: &BitImage) {
195        for y in 0..bm.height {
196            let row_start = y * bm.width;
197            let row_bits = &bm.as_bits()[row_start..row_start + bm.width];
198            let mut x = 0usize;
199
200            while x < bm.width {
201                // Skip to the next black pixel using word-level scan (64 bits/cycle)
202                if let Some(black_offset) = row_bits[x..].first_one() {
203                    let x1 = x + black_offset;
204                    // Find the end of this black run
205                    let run_length = row_bits[x1..].first_zero().unwrap_or(bm.width - x1);
206                    let x2 = x1 + run_length - 1;
207
208                    self.add_single_run(y as i32, x1 as i32, x2 as i32);
209                    x = x2 + 1;
210                } else {
211                    break; // No more black pixels in this row
212                }
213            }
214        }
215    }
216
217    // ── Connected-component labeling (union-find on runs) ───────────────
218
219    /// Assign `ccid` to every run using single-pass union-find.
220    ///
221    /// This is a direct port of `CCImage::make_ccids_by_analysis()`.
222    ///
223    /// **Algorithm summary:**
224    /// 1. Sort runs by (y, x1).
225    /// 2. For each run on line y, scan the runs on line y−1 that horizontally
226    ///    overlap (with 1-pixel adjacency, i.e. x1−1..x2+1).
227    /// 3. Union all overlapping previous-line runs with the current run.
228    /// 4. Path-compress the union-find map.
229    pub fn make_ccids_by_analysis(&mut self) {
230        // Sort runs
231        self.runs.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
232
233        let n_runs = self.runs.len();
234        if n_runs == 0 {
235            return;
236        }
237
238        // Union-find map: umap[id] is the parent of id.  A root satisfies
239        // umap[id] == id.
240        let mut umap: Vec<i32> = Vec::new();
241
242        // `p` is the pointer into runs for the "previous line" scan window.
243        let mut p: usize = 0;
244
245        for n in 0..n_runs {
246            let y = self.runs[n].y;
247            let x1 = self.runs[n].x1 - 1; // 1-pixel adjacency
248            let x2 = self.runs[n].x2 + 1;
249
250            // id will hold the representative for this run's CC.
251            // Initialize to "no id yet" by setting beyond current umap.
252            let mut id: i32 = umap.len() as i32;
253
254            // Advance p past runs that are above line y-1
255            while p < n_runs && self.runs[p].y < y - 1 {
256                p += 1;
257            }
258
259            // Scan previous-line runs that could overlap
260            let mut pp = p;
261            while pp < n_runs && self.runs[pp].y < y && self.runs[pp].x1 <= x2 {
262                if self.runs[pp].x2 >= x1 {
263                    // This previous run overlaps — union.
264                    let mut oid = self.runs[pp].ccid;
265                    // Path compression: find root
266                    while (oid as usize) < umap.len() && umap[oid as usize] < oid {
267                        oid = umap[oid as usize];
268                    }
269
270                    if id >= umap.len() as i32 {
271                        // First overlap: adopt the previous run's root
272                        id = oid;
273                    } else if id < oid {
274                        // Merge: point oid → id
275                        if (oid as usize) < umap.len() {
276                            umap[oid as usize] = id;
277                        }
278                    } else if oid < id {
279                        // Merge: point id → oid
280                        if (id as usize) < umap.len() {
281                            umap[id as usize] = oid;
282                        }
283                        id = oid;
284                    }
285
286                    // Freshen previous run's ccid
287                    self.runs[pp].ccid = id;
288
289                    // Stop if this previous run extends past our current run
290                    if self.runs[pp].x2 >= x2 {
291                        break;
292                    }
293                }
294                pp += 1;
295            }
296
297            // Assign id to current run
298            self.runs[n].ccid = id;
299            if id >= umap.len() as i32 {
300                // Create a new root
301                let new_id = umap.len() as i32;
302                umap.push(new_id);
303                self.runs[n].ccid = new_id;
304            }
305        }
306
307        // Final path compression pass — flatten every ccid to its root
308        for n in 0..n_runs {
309            let mut ccid = self.runs[n].ccid;
310            while (ccid as usize) < umap.len() && umap[ccid as usize] < ccid {
311                ccid = umap[ccid as usize];
312            }
313            // Full path compression: also update intermediate nodes
314            let root = ccid;
315            let mut id = self.runs[n].ccid;
316            while id != root {
317                let next = umap[id as usize];
318                umap[id as usize] = root;
319                id = next;
320            }
321            self.runs[n].ccid = root;
322        }
323    }
324
325    // ── Build CC descriptors from labeled runs ──────────────────────────
326
327    /// Compute CC descriptors (bounding boxes, pixel counts, run ranges)
328    /// from the ccid labels on runs.
329    ///
330    /// Direct port of `CCImage::make_ccs_from_ccids()`.
331    pub fn make_ccs_from_ccids(&mut self) {
332        if self.runs.is_empty() {
333            self.ccs.clear();
334            return;
335        }
336
337        // Find maximum ccid
338        let mut maxccid = (self.nregularccs as i32) - 1;
339        for run in &self.runs {
340            if run.ccid > maxccid {
341                maxccid = run.ccid;
342            }
343        }
344        if maxccid < 0 {
345            self.ccs.clear();
346            return;
347        }
348
349        // Renumber: rmap[old_ccid] → new sequential id, or -1 if unused.
350        let map_size = (maxccid + 1) as usize;
351        let mut rmap = vec![-1i32; map_size];
352        for run in &self.runs {
353            if run.ccid >= 0 {
354                rmap[run.ccid as usize] = 1; // mark as used
355            }
356        }
357        let mut nid = 0i32;
358        for entry in rmap.iter_mut() {
359            if *entry > 0 {
360                *entry = nid;
361                nid += 1;
362            }
363        }
364
365        // Adjust nregularccs
366        while self.nregularccs > 0
367            && (self.nregularccs - 1 < map_size)
368            && rmap[self.nregularccs - 1] < 0
369        {
370            self.nregularccs -= 1;
371        }
372        if self.nregularccs > 0 && self.nregularccs <= map_size {
373            self.nregularccs = (1 + rmap[self.nregularccs - 1]) as usize;
374        }
375
376        // Initialize CC descriptors
377        let nid_us = nid as usize;
378        self.ccs = vec![CC::default(); nid_us];
379
380        // Count runs per CC
381        for run in &self.runs {
382            if run.ccid < 0 {
383                continue;
384            }
385            let new_id = rmap[run.ccid as usize];
386            if new_id >= 0 {
387                self.ccs[new_id as usize].nrun += 1;
388            }
389        }
390
391        // Compute first-run positions
392        let mut frun = 0i32;
393        // We'll reuse rmap as a "current insertion position" array
394        let mut positions = vec![0i32; nid_us];
395        for i in 0..nid_us {
396            self.ccs[i].frun = frun;
397            positions[i] = frun;
398            frun += self.ccs[i].nrun;
399        }
400
401        // Relabel runs and copy into sorted order
402        let mut sorted_runs = vec![
403            Run {
404                y: 0,
405                x1: 0,
406                x2: 0,
407                ccid: -1
408            };
409            frun as usize
410        ];
411        for run in &self.runs {
412            if run.ccid < 0 {
413                continue;
414            }
415            let new_id = rmap[run.ccid as usize];
416            if new_id < 0 {
417                continue;
418            }
419            let pos = positions[new_id as usize] as usize;
420            sorted_runs[pos] = Run {
421                y: run.y,
422                x1: run.x1,
423                x2: run.x2,
424                ccid: new_id,
425            };
426            positions[new_id as usize] += 1;
427        }
428        self.runs = sorted_runs;
429
430        // Finalize each CC: sort its runs and compute bounding box + npix
431        for i in 0..nid_us {
432            let cc = &self.ccs[i];
433            let start = cc.frun as usize;
434            let end = start + cc.nrun as usize;
435
436            // Sort runs within this CC
437            self.runs[start..end].sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
438
439            // Compute bounds and pixel count
440            let mut npix = 0i32;
441            let mut xmin = i32::MAX;
442            let mut xmax = i32::MIN;
443            let mut ymin = i32::MAX;
444            let mut ymax = i32::MIN;
445
446            for run in &self.runs[start..end] {
447                xmin = xmin.min(run.x1);
448                xmax = xmax.max(run.x2);
449                ymin = ymin.min(run.y);
450                ymax = ymax.max(run.y);
451                npix += run.x2 - run.x1 + 1;
452            }
453
454            let cc = &mut self.ccs[i];
455            cc.npix = npix;
456            cc.bb = BBox {
457                xmin,
458                ymin,
459                xmax: xmax + 1, // exclusive
460                ymax: ymax + 1, // exclusive
461            };
462        }
463    }
464
465    // ── Noise removal ───────────────────────────────────────────────────
466
467    /// Remove CCs with ≤ `tinysize` pixels.
468    ///
469    /// This is the "cleaning" step: at 300 DPI tinysize = 3, so isolated
470    /// specks of 1–3 pixels are removed.  (cjb2.cpp notes that halftone
471    /// regions should be exempted, but neither cjb2 nor we do that.)
472    pub fn erase_tiny_ccs(&mut self) {
473        for i in 0..self.ccs.len() {
474            if self.ccs[i].npix <= self.tinysize {
475                let frun = self.ccs[i].frun as usize;
476                let nrun = self.ccs[i].nrun as usize;
477                self.ccs[i].nrun = 0;
478                self.ccs[i].npix = 0;
479                for r in frun..frun + nrun {
480                    if r < self.runs.len() {
481                        self.runs[r].ccid = -1;
482                    }
483                }
484            }
485        }
486    }
487
488    // ── Merge small / split large CCs ───────────────────────────────────
489
490    /// The critical step that the Lutz-based code was missing entirely.
491    ///
492    /// ## Small CC merging
493    /// Any CC whose bounding box fits within `smallsize × smallsize` is
494    /// merged with other nearby small CCs in the same grid cell.  The grid
495    /// cell size is `largesize`.  This catches:
496    /// - Diacritical marks (dots over i/j, umlauts, tildes)
497    /// - Punctuation fragments
498    /// - Serif fragments that separated during binarization
499    ///
500    /// ## Large CC splitting
501    /// Any CC whose bounding box exceeds `largesize` in either dimension
502    /// has its runs re-assigned to grid cells.  Long runs that span multiple
503    /// grid cells are physically split.  This catches:
504    /// - Lines and rules
505    /// - Touching character groups
506    /// - Decorative borders
507    ///
508    /// After reassignment, `make_ccs_from_ccids()` is called again to
509    /// recompute all CC descriptors.
510    pub fn merge_and_split_ccs(&mut self) {
511        if self.ccs.is_empty() {
512            return;
513        }
514
515        let splitsize = self.largesize;
516        let mut ncc = self.ccs.len() as i32;
517        let mut extra_runs: Vec<Run> = Vec::new();
518
519        // We need a way to map (gridi, gridj, ccid) → new ccid.
520        // Using a HashMap like DjVuLibre's GMap.
521        use std::collections::HashMap;
522        let mut grid_map: HashMap<(i16, i16, i32), i32> = HashMap::new();
523
524        self.nregularccs = self.ccs.len();
525
526        let makeccid =
527            |key: (i16, i16, i32), map: &mut HashMap<(i16, i16, i32), i32>, ncc: &mut i32| -> i32 {
528                if let Some(&id) = map.get(&key) {
529                    id
530                } else {
531                    let id = *ncc;
532                    map.insert(key, id);
533                    *ncc += 1;
534                    id
535                }
536            };
537
538        for ccid in 0..self.ccs.len() {
539            let cc = &self.ccs[ccid];
540            if cc.nrun <= 0 {
541                continue;
542            }
543
544            let cc_height = cc.bb.height();
545            let cc_width = cc.bb.width();
546            let frun = cc.frun as usize;
547            let nrun = cc.nrun as usize;
548
549            if cc_height <= self.smallsize && cc_width <= self.smallsize {
550                // ── Merge small CC ───────────────────────────────────
551                // Map all runs to the same grid cell, with ccid = -1
552                // so that unrelated small CCs in the same cell merge.
553                let gridi = ((cc.bb.ymin + cc.bb.ymax) / splitsize / 2) as i16;
554                let gridj = ((cc.bb.xmin + cc.bb.xmax) / splitsize / 2) as i16;
555                let key = (gridi, gridj, -1);
556                let new_ccid = makeccid(key, &mut grid_map, &mut ncc);
557                for r in frun..frun + nrun {
558                    if r < self.runs.len() {
559                        self.runs[r].ccid = new_ccid;
560                    }
561                }
562            } else if cc_height >= self.largesize || cc_width >= self.largesize {
563                // ── Split large CC ───────────────────────────────────
564                for r in frun..frun + nrun {
565                    if r >= self.runs.len() {
566                        continue;
567                    }
568
569                    let run_y = self.runs[r].y;
570                    let run_x1 = self.runs[r].x1;
571                    let run_x2 = self.runs[r].x2;
572
573                    let gridi = (run_y / splitsize) as i16;
574                    let gridj_start = (run_x1 / splitsize) as i16;
575                    let gridj_end = (run_x2 / splitsize) as i16;
576
577                    let key = (gridi, gridj_start, ccid as i32);
578                    let new_ccid = makeccid(key, &mut grid_map, &mut ncc);
579                    self.runs[r].ccid = new_ccid;
580
581                    if gridj_end > gridj_start {
582                        // Run spans multiple grid columns — split it.
583                        // Truncate the original run to its first grid cell.
584                        let orig_x2 = self.runs[r].x2;
585                        self.runs[r].x2 = (gridj_start as i32 + 1) * splitsize - 1;
586
587                        // Create new runs for intermediate grid cells
588                        let mut current_gridj = gridj_start + 1;
589                        while current_gridj < gridj_end {
590                            let cell_x1 = current_gridj as i32 * splitsize;
591                            let cell_x2 = cell_x1 + splitsize - 1;
592                            let key = (gridi, current_gridj, ccid as i32);
593                            let cell_ccid = makeccid(key, &mut grid_map, &mut ncc);
594                            extra_runs.push(Run {
595                                y: run_y,
596                                x1: cell_x1,
597                                x2: cell_x2,
598                                ccid: cell_ccid,
599                            });
600                            current_gridj += 1;
601                        }
602
603                        // Create run for the last grid cell
604                        let last_x1 = gridj_end as i32 * splitsize;
605                        let key = (gridi, gridj_end, ccid as i32);
606                        let last_ccid = makeccid(key, &mut grid_map, &mut ncc);
607                        extra_runs.push(Run {
608                            y: run_y,
609                            x1: last_x1,
610                            x2: orig_x2,
611                            ccid: last_ccid,
612                        });
613                    }
614                }
615            }
616            // Normal-sized CCs keep their existing ccid — no changes needed.
617        }
618
619        // Append any extra runs that were created by splitting
620        self.runs.append(&mut extra_runs);
621
622        // Recompute all CC descriptors from the updated ccids
623        self.make_ccs_from_ccids();
624    }
625
626    // ── Reading-order sort ──────────────────────────────────────────────
627
628    /// Sort CCs in approximate reading order: top-to-bottom by text line,
629    /// left-to-right within each line.
630    ///
631    /// This is important for JB2 encoding efficiency because the encoder
632    /// uses relative positioning — nearby symbols in encoding order should
633    /// be spatially close.  It also means the dictionary sees similar
634    /// characters (same font, same size) in sequence, improving
635    /// cross-coding compression.
636    ///
637    /// Direct port of `CCImage::sort_in_reading_order()`.
638    pub fn sort_in_reading_order(&mut self) {
639        let n = self.nregularccs;
640        if n < 2 {
641            return;
642        }
643
644        // Work on a copy of the regular CCs
645        let mut cc_arr: Vec<(usize, CC)> = self.ccs[..n]
646            .iter()
647            .enumerate()
648            .map(|(i, cc)| (i, cc.clone()))
649            .collect();
650
651        // Sort by top edge ascending (lowest ymin first) for Top-Down coordinates.
652        // This ensures Top-to-Bottom reading order.
653        cc_arr.sort_by(|a, b| {
654            a.1.bb
655                .ymin
656                .cmp(&b.1.bb.ymin)
657                .then(a.1.bb.xmin.cmp(&b.1.bb.xmin))
658                .then(a.1.frun.cmp(&b.1.frun))
659        });
660
661        // Determine max vertical deviation for line grouping
662        let maxtopchange = (self.width / 40).max(32);
663
664        // Group into text lines and sort within each line
665        let mut ccno = 0usize;
666        while ccno < n {
667            let line_start_ymin = cc_arr[ccno].1.bb.ymin;
668            // Scan for the end of this line (items that are vertically close)
669
670            let mut nccno = ccno + 1;
671            while nccno < n {
672                let curr_ymin = cc_arr[nccno].1.bb.ymin;
673
674                // If the next items top edge is significantly below the line start, it's a new line
675                if curr_ymin > line_start_ymin + maxtopchange {
676                    break;
677                }
678                nccno += 1;
679            }
680
681            // Sort this line left-to-right (by xmin)
682            cc_arr[ccno..nccno].sort_by(|a, b| a.1.bb.xmin.cmp(&b.1.bb.xmin));
683
684            // Move to next line
685            ccno = nccno;
686        }
687
688        // Write back and relabel runs
689        let mut new_ccs = Vec::with_capacity(self.ccs.len());
690        let mut old_to_new = vec![0usize; self.ccs.len()];
691
692        for (new_idx, (old_idx, cc)) in cc_arr.into_iter().enumerate() {
693            new_ccs.push(cc);
694            old_to_new[old_idx] = new_idx;
695        }
696
697        // Append the non-regular CCs
698        for i in n..self.ccs.len() {
699            let new_idx = new_ccs.len();
700            new_ccs.push(self.ccs[i].clone());
701            old_to_new[i] = new_idx;
702        }
703
704        self.ccs = new_ccs;
705
706        // Remap runs
707        for run in &mut self.runs {
708            if run.ccid >= 0 {
709                run.ccid = old_to_new[run.ccid as usize] as i32;
710            }
711        }
712    }
713
714    // ── Bitmap extraction ───────────────────────────────────────────────
715
716    /// Extract a bitmap for a single CC by painting its runs into a fresh
717    /// `BitImage`.
718    pub fn get_bitmap_for_cc(&self, ccid: usize) -> Option<BitImage> {
719        if ccid >= self.ccs.len() {
720            return None;
721        }
722        let cc = &self.ccs[ccid];
723        let bb = &cc.bb;
724        let w = bb.width();
725        let h = bb.height();
726        if w <= 0 || h <= 0 {
727            return None;
728        }
729
730        let mut bm = BitImage::new(w as u32, h as u32).ok()?;
731        let frun = cc.frun as usize;
732        let nrun = cc.nrun as usize;
733
734        for i in frun..frun + nrun {
735            if i >= self.runs.len() {
736                break;
737            }
738            let run = &self.runs[i];
739            let row = run.y - bb.ymin;
740            for x in run.x1..=run.x2 {
741                let col = x - bb.xmin;
742                bm.set_usize(col as usize, row as usize, true);
743            }
744        }
745
746        Some(bm)
747    }
748
749    // ── High-level pipeline ─────────────────────────────────────────────
750
751    /// Run the full CC analysis pipeline:
752    ///
753    /// 1. `make_ccids_by_analysis()` — union-find labeling
754    /// 2. `make_ccs_from_ccids()` — build descriptors
755    /// 3. `sort_in_reading_order()` — reading-order sort
756    ///
757    /// After this, iterate `0..self.ccs.len()` and call
758    /// `get_bitmap_for_cc(i)` to extract symbol bitmaps.
759    pub fn analyze(&mut self, losslevel: i32) {
760        let _ = losslevel;
761        self.make_ccids_by_analysis();
762        self.make_ccs_from_ccids();
763        self.sort_in_reading_order();
764    }
765
766    /// Return lightweight component descriptors without allocating bitmaps.
767    pub fn extract_shape_refs(&self) -> Vec<ShapeRef> {
768        let mut shapes = Vec::with_capacity(self.ccs.len());
769        for (ccid, cc) in self.ccs.iter().enumerate() {
770            if cc.nrun <= 0 {
771                continue;
772            }
773            shapes.push(ShapeRef {
774                ccid,
775                bbox: cc.bb,
776                black_pixels: cc.npix.max(0) as usize,
777                run_count: cc.nrun.max(0) as usize,
778            });
779        }
780        shapes
781    }
782
783    /// Convert the analyzed CCs into (bitmap, bounding_box) pairs ready
784    /// for JB2 encoding, filtering out empty results.
785    pub fn extract_shapes(&self) -> Vec<(BitImage, BBox)> {
786        let mut shapes = Vec::with_capacity(self.ccs.len());
787        for shape in self.extract_shape_refs() {
788            if let Some(bm) = self.get_bitmap_for_cc(shape.ccid) {
789                shapes.push((bm, shape.bbox));
790            }
791        }
792        shapes
793    }
794}
795
796// ─── Convenience entry point ────────────────────────────────────────────────
797
798/// Perform connected-component analysis on a `BitImage` and return the
799/// extracted shapes with their bounding boxes, ready for JB2 encoding.
800///
801/// This replaces the Lutz-based `find_connected_components()` and the
802/// entire `extract_symbols()` pipeline from `jbig2lutz.rs`.
803///
804/// ## Parameters
805/// - `image`: the full-page bilevel image
806/// - `dpi`: image resolution (typically 300 for scanned documents)
807/// - `losslevel`: 0 = lossless (no cleaning), >0 enables noise removal
808///
809/// ## Returns
810/// A `CCImage` with the full analysis complete.  Call `extract_shapes()`
811/// to get `(BitImage, BBox)` pairs.
812pub fn analyze_page(image: &BitImage, dpi: i32, losslevel: i32) -> CCImage {
813    let mut ccimg = CCImage::new(image.width as i32, image.height as i32, dpi);
814    ccimg.add_bitmap_runs(image);
815    ccimg.analyze(losslevel);
816    ccimg
817}
818
819/// Extract symbols suitable for JBIG2 symbol dictionary creation.
820///
821/// Returns a Vec of (bitmap, x, y, width, height) where x,y is the position
822/// on the page and width,height are dimensions.
823///
824/// This format can be converted to the structures expected by the JBIG2
825/// symbol matching and encoding pipeline.
826pub fn extract_symbols_for_jbig2(cc_image: &CCImage) -> Vec<(BitImage, i32, i32, i32, i32)> {
827    let shapes = cc_image.extract_shapes();
828    shapes
829        .into_iter()
830        .map(|(bitmap, bbox)| (bitmap, bbox.xmin, bbox.ymin, bbox.width(), bbox.height()))
831        .collect()
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837
838    fn make_test_image() -> BitImage {
839        // Create a small test image with two separate blobs
840        let mut bm = BitImage::new(40, 20).unwrap();
841        // Blob 1: 5x5 at (2, 2)
842        for y in 2..7 {
843            for x in 2..7 {
844                bm.set_usize(x, y, true);
845            }
846        }
847        // Blob 2: 5x5 at (20, 10)
848        for y in 10..15 {
849            for x in 20..25 {
850                bm.set_usize(x, y, true);
851            }
852        }
853        bm
854    }
855
856    #[test]
857    fn test_run_extraction() {
858        let bm = make_test_image();
859        let mut ccimg = CCImage::new(40, 20, 300);
860        ccimg.add_bitmap_runs(&bm);
861        // Each blob has 5 rows, each row is one run → 10 runs total
862        assert_eq!(ccimg.runs.len(), 10);
863    }
864
865    #[test]
866    fn test_cc_analysis_finds_two_components() {
867        let bm = make_test_image();
868        let mut ccimg = CCImage::new(40, 20, 300);
869        ccimg.add_bitmap_runs(&bm);
870        ccimg.make_ccids_by_analysis();
871        ccimg.make_ccs_from_ccids();
872
873        assert_eq!(ccimg.ccs.len(), 2);
874        assert_eq!(ccimg.ccs[0].npix, 25);
875        assert_eq!(ccimg.ccs[1].npix, 25);
876    }
877
878    #[test]
879    fn test_full_pipeline() {
880        let bm = make_test_image();
881        let ccimg = analyze_page(&bm, 300, 0);
882        let shapes = ccimg.extract_shapes();
883
884        assert_eq!(shapes.len(), 2);
885        for (bitmap, bb) in &shapes {
886            assert_eq!(bitmap.width, 5);
887            assert_eq!(bitmap.height, 5);
888            assert_eq!(bb.width(), 5);
889            assert_eq!(bb.height(), 5);
890        }
891    }
892
893    #[test]
894    fn test_tiny_cc_removal() {
895        let mut bm = BitImage::new(40, 20).unwrap();
896        // One real blob
897        for y in 2..7 {
898            for x in 2..7 {
899                bm.set_usize(x, y, true);
900            }
901        }
902        // One tiny speck (1 pixel)
903        bm.set_usize(30, 10, true);
904
905        let ccimg = analyze_page(&bm, 300, 1); // losslevel > 0 enables cleaning
906        let shapes = ccimg.extract_shapes();
907
908        // The speck should have been removed (tinysize at 300 DPI = 3)
909        assert_eq!(shapes.len(), 1);
910        assert_eq!(shapes[0].0.width, 5);
911    }
912}