bindHocrToSnapshot function

HocrSnapshotBinding bindHocrToSnapshot({
  1. required HocrDocument document,
  2. required CanonicalTextSnapshot target,
  3. HocrBindLimits limits = const HocrBindLimits(),
})

Binds document's positioned text onto target's canonical text: each word maps to the UTF-16 range its text selects in the target, lines whose words carry no text map their own text, parents aggregate their children's ranges, and every outcome stays observable.

The scan walks the target text with one reading cursor across all pages in document order: a unit resolves at the first occurrence at or after the cursor, falls back to the document start once when nothing follows the cursor, and is missing when the content never occurs. More than one occurrence, or an alternative reading on the unit or an ancestor, makes the unit ambiguous with its bounded candidate list — repeated quotes, alternative readings and truncated scans never certify uniqueness. Astral characters and diacritics compare as exact UTF-16 code units.

The region zone of a line is its nearest boxed ancestor among paragraph, block and page frame (the DjVu rule for skipped levels); words without a box bind but emit no zone; pages without a frame emit no spatial page.

Throws HocrException when the projection exceeds the declared work budget.

Implementation

HocrSnapshotBinding bindHocrToSnapshot({
  required final HocrDocument document,
  required final CanonicalTextSnapshot target,
  final HocrBindLimits limits = const HocrBindLimits(),
}) {
  limits.requireValid();
  final text = target.text;
  var work = 0;
  var wordsResolved = 0;
  var wordsAmbiguous = 0;
  var wordsMissing = 0;
  final ambiguousUnits = <HocrAmbiguousUnit>[];
  final spatialPages = <SpatialTextPage>[];
  final pageIndexes = <int>[];
  var cursor = 0;

  int find(final String content, final int from) {
    final scanWork = text.length - from;
    if (scanWork > limits.maxProjectionWork - work) {
      throw const HocrException('hOCR binding exceeds the projection work budget.');
    }
    work += scanWork;

    return text.indexOf(content, from);
  }

  _UnitOutcome mapUnit({
    required final String content,
    required final bool hasAlternativeReadings,
    required final HocrUnitPlace place,
  }) {
    var from = cursor <= text.length ? cursor : text.length;
    var found = find(content, from);
    if (found < 0 && from > 0) {
      from = 0;
      found = find(content, 0);
    }
    if (found < 0) {
      wordsMissing++;

      return _UnitOutcome(cursor, cursor, cursor);
    }
    final candidates = <(int, int)>[];
    var scan = 0;
    while (true) {
      final at = find(content, scan);
      if (at < 0) break;
      candidates.add((at, at + content.length));
      if (candidates.length >= limits.maxCandidateOccurrences) break;
      scan = at + 1;
    }
    if (candidates.length > 1 || hasAlternativeReadings) {
      wordsAmbiguous++;
      ambiguousUnits.add(
        HocrAmbiguousUnit(
          place: place,
          content: content,
          candidates: candidates,
          chosen: (found, found + content.length),
        ),
      );
    } else {
      wordsResolved++;
    }

    return _UnitOutcome(found, found + content.length, found + content.length);
  }

  for (var pageIndex = 0; pageIndex < document.pages.length; pageIndex++) {
    final page = document.pages[pageIndex];
    final frame = page.frame;
    final zones = <SpatialTextZone>[];
    var hasConfidence = false;
    var lineIndex = 0;
    // Consecutive lines sharing a region buffer until the region changes,
    // so the region zone can carry the aggregate of its lines' ranges and
    // still precede them in the depth-first order.
    HocrElement? openRegion;
    var regionStart = -1;
    var regionEnd = -1;
    final regionLines = <List<SpatialTextZone>>[];

    void flushRegion() {
      final region = openRegion;
      if (region == null || regionLines.isEmpty) {
        openRegion = null;
        regionLines.clear();

        return;
      }
      final box = region is HocrPage ? frame! : region.box!;
      zones.add(
        SpatialTextZone(
          kind: SpatialZoneKind.region,
          rect: box.rect,
          textStart: regionStart,
          textEnd: regionEnd,
        ),
      );
      for (final group in regionLines) {
        zones.addAll(group);
      }
      openRegion = null;
      regionLines.clear();
    }

    void visitLine(final HocrLine line, final HocrElement? region) {
      final currentLine = lineIndex++;
      final lineAlternatives = line.hasAlternativeReadings;
      var lineStart = -1;
      var lineEnd = -1;
      var lineMapped = false;
      final wordZones = <SpatialTextZone>[];
      for (var wordIndex = 0; wordIndex < line.words.length; wordIndex++) {
        final word = line.words[wordIndex];
        final confidence = word.confidence;
        if (confidence != null && !confidence.isUnknown) hasConfidence = true;
        final content = bindableContent(word.text);
        if (content.isEmpty) continue;
        final outcome = mapUnit(
          content: content,
          hasAlternativeReadings: lineAlternatives || word.hasAlternativeReadings,
          place: HocrUnitPlace(pageIndex: pageIndex, lineIndex: currentLine, wordIndex: wordIndex),
        );
        lineMapped = true;
        lineStart = lineStart < 0 ? outcome.textStart : math.min(lineStart, outcome.textStart);
        lineEnd = lineEnd < 0 ? outcome.textEnd : math.max(lineEnd, outcome.textEnd);
        cursor = outcome.newCursor;
        final wordBox = word.box;
        if (wordBox != null) {
          wordZones.add(
            SpatialTextZone(
              kind: SpatialZoneKind.word,
              rect: wordBox.rect,
              textStart: outcome.textStart,
              textEnd: outcome.textEnd,
              confidence: confidence?.shared,
            ),
          );
        }
      }
      if (!lineMapped) {
        final content = bindableContent(line.text);
        if (content.isNotEmpty) {
          final outcome = mapUnit(
            content: content,
            hasAlternativeReadings: lineAlternatives,
            place: HocrUnitPlace(pageIndex: pageIndex, lineIndex: currentLine),
          );
          lineMapped = true;
          lineStart = outcome.textStart;
          lineEnd = outcome.textEnd;
          cursor = outcome.newCursor;
        }
      }
      if (!lineMapped || region == null) return;
      if (!identical(openRegion, region)) {
        flushRegion();
        openRegion = region;
        regionStart = lineStart;
        regionEnd = lineEnd;
      } else {
        regionStart = math.min(regionStart, lineStart);
        regionEnd = math.max(regionEnd, lineEnd);
      }
      regionLines.add(<SpatialTextZone>[
        SpatialTextZone(
          kind: SpatialZoneKind.line,
          rect: line.box.rect,
          textStart: lineStart,
          textEnd: lineEnd,
        ),
        ...wordZones,
      ]);
    }

    void visit(final List<HocrElement> nodes, final HocrElement? region) {
      for (final node in nodes) {
        switch (node) {
          case HocrBlock():
            visit(node.children, node);
          case HocrParagraph():
            visit(node.children, node.box == null ? region : node);
          case HocrLine():
            visitLine(node, region);
          case HocrPage():
          case HocrWord():
            break;
        }
      }
    }

    visit(page.children, frame == null ? null : page);
    flushRegion();
    if (frame == null) continue;
    spatialPages.add(
      SpatialTextPage(
        width: frame.x1.toDouble(),
        height: frame.y1.toDouble(),
        unit: SpatialUnit.pixel,
        rotation: 0,
        textLength: text.length,
        provenance: SpatialTextProvenance(
          origin: SpatialTextOrigin.importedOcr,
          hasConfidence: hasConfidence,
          softwareName: document.system,
          softwareVersion: null,
        ),
        zones: zones,
      ),
    );
    pageIndexes.add(pageIndex);
  }

  return HocrSnapshotBinding._(
    document: document,
    target: target,
    pages: spatialPages,
    pageIndexes: pageIndexes,
    wordsResolved: wordsResolved,
    wordsAmbiguous: wordsAmbiguous,
    wordsMissing: wordsMissing,
    ambiguousUnits: ambiguousUnits,
  );
}