freepdfeditor/spike/B_reconstruction/Corpus.cpp

157 lines
5.8 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
//
// Corpus.cpp — synthetic labelled corpus generator for Spike B.
#include "Corpus.h"
#include <random>
#include <sstream>
#include <string>
#include <vector>
namespace freepdfeditor::spike::b {
namespace {
// A simple deterministic PRNG so the corpus is reproducible run-to-run, which
// matters for the F1 gate: a regression must be a real change, not PRNG drift.
// Seed is fixed; to re-roll the corpus, change the seed here.
std::mt19937_64& rng()
{
static std::mt19937_64 r(0xF0BEE75EEDull);
return r;
}
std::size_t rand_size(std::size_t lo, std::size_t hi)
{
std::uniform_int_distribution<std::size_t> d(lo, hi);
return d(rng());
}
// Lorem-ipsum-ish word pool. Using real words rather than random letters makes
// the synthetic text exercise the same word-break logic the real pipeline
// would, and makes failures readable in the report.
const std::vector<std::u32string> words = {
U"the", U"quick", U"brown", U"fox", U"jumps", U"over", U"lazy", U"dog",
U"hello", U"world", U"PDF", U"editor", U"reflow", U"paragraph", U"line",
U"text", U"font", U"glyph", U"unicode", U"baseline", U"column", U"margin",
U"leading", U"justify", U"shaping", U"harfbuzz", U"freetype", U"icu",
U"reconstruct", U"boundary", U"cluster", U"reading", U"order", U"document"
};
// Build a line of glyphs from a sequence of Unicode codepoints, starting at
// (x, y), advancing by `advance` per glyph. One GlyphRun per line is the
// simplest shape that exercises line detection (cluster by y) and paragraph
// grouping (consecutive lines).
GlyphRun make_line(const std::u32string& text, float x, float y,
float size, float advance, float confidence = 1.0f)
{
GlyphRun run;
run.size = size;
run.rotation = 0.0f;
run.glyphs.reserve(text.size());
float px = x;
for (char32_t cp : text) {
Glyph g;
g.unicode = static_cast<std::uint32_t>(cp);
g.code = g.unicode; // synthetic: code == unicode
g.gid = g.unicode; // synthetic: gid == unicode
g.confidence = confidence;
g.origin = {px, y};
g.advance = advance;
run.glyphs.push_back(g);
px += advance;
}
return run;
}
std::u32string make_text_line(float column_left, float column_right,
float advance, bool& reached_right)
{
// Build a line of words until we reach or exceed the column right edge,
// simulating wrapping. `reached_right` tells the caller whether the line
// wrapped (true) or ended short (false), which is the paragraph-break signal.
std::u32string line;
float width = column_right - column_left;
std::size_t glyphs_for_full = std::size_t(width / advance);
std::size_t target = rand_size(std::size_t(glyphs_for_full * 0.85f),
std::size_t(glyphs_for_full * 1.05f));
std::size_t written = 0;
while (written < target) {
const auto& w = words[rand_size(0, words.size() - 1)];
if (!line.empty()) { line.push_back(U' '); written += 1; }
for (char32_t c : w) { line.push_back(c); ++written; }
if (written >= target) break;
}
reached_right = written >= glyphs_for_full;
return line;
}
SyntheticDoc make_doc(std::size_t idx)
{
SyntheticDoc doc;
doc.name = "synthetic-" + std::to_string(idx);
const float column_left = 72.0f;
const float column_right = 540.0f; // 612 - 72, US Letter margins
const float advance = 6.0f; // ~12pt font, 0.5 em advance
const float size = 12.0f;
const float leading = size * 1.2f; // 14.4pt
const float para_gap = leading * 1.6f;
float y = 720.0f; // start near the top of the page
std::size_t n_paras = rand_size(3, 8);
for (std::size_t p = 0; p < n_paras; ++p) {
// Paragraph truth: record its vertical extent.
GroundTruthParagraph gtp;
gtp.left = column_left;
gtp.right = column_right;
gtp.top = y;
std::size_t n_lines = rand_size(1, 5);
bool is_list = (p > 0 && rand_size(0, 9) == 0); // ~10% of paras are list items
std::u32string para_text;
for (std::size_t l = 0; l < n_lines; ++l) {
bool reached_right = false;
std::u32string line_text = make_text_line(column_left, column_right,
advance, reached_right);
// The last line of a paragraph ends short (didn't reach the right).
if (l == n_lines - 1) reached_right = false;
GlyphRun run = make_line(line_text, column_left, y, size, advance);
if (is_list && l == 0) {
// Prepend a marker. In the real pipeline the marker is detected;
// here we set it on the line so the pipeline can read it.
run.glyphs.insert(run.glyphs.begin(), Glyph{});
// We don't have a Line yet; the marker detection happens in
// group_paragraphs via the Line.marker field, which detect_lines
// doesn't populate. For the spike, list handling is exercised
// by the paragraph-break logic (a list line is short).
}
doc.runs.push_back(std::move(run));
if (!para_text.empty()) para_text.push_back(U'\n');
para_text += line_text;
y -= leading;
}
gtp.bottom = y + leading; // bottom of the last line's baseline
gtp.text = para_text;
doc.truth.push_back(gtp);
y -= para_gap; // gap before the next paragraph
}
return doc;
}
} // namespace
std::vector<SyntheticDoc> generate_corpus(std::size_t n)
{
std::vector<SyntheticDoc> out;
out.reserve(n);
for (std::size_t i = 0; i < n; ++i) out.push_back(make_doc(i));
return out;
}
} // namespace freepdfeditor::spike::b