This commit is contained in:
saqib mir
2026-08-03 11:26:44 +05:30
parent f89ccfbe5d
commit d85c3a3e43
35 changed files with 1529 additions and 3 deletions
+10
View File
@@ -64,6 +64,16 @@ add_library(pdfengine OBJECT
src/image/decoder/mask_processor.cpp
src/image/decoder/image_decoder_factory.cpp
src/ocr/ocr_cache.cpp
src/layout/layout_arena.cpp
src/layout/spatial_index.cpp
src/layout/multi_level_cache.cpp
src/layout/layout_session.cpp
src/layout/pass_registry.cpp
src/layout/layout_engine.cpp
src/layout/passes/line_detection_pass.cpp
src/layout/passes/paragraph_detection_pass.cpp
src/layout/passes/column_detection_pass.cpp
src/layout/passes/region_detection_pass.cpp
src/parser/lexer.cpp
src/parser/parser.cpp
src/parser/content_builder.cpp
@@ -0,0 +1,64 @@
#pragma once
#include <pdfengine/geometry/rect.hpp>
#include <cmath>
namespace pdfengine {
namespace geometry {
// 2D Affine Transformation Matrix [a b c d e f]
// [ x' ] [ a c e ] [ x ]
// [ y' ] = [ b d f ] [ y ]
// [ 1 ] [ 0 0 1 ] [ 1 ]
struct Matrix {
float a = 1.0f; // Scale X
float b = 0.0f; // Shear Y
float c = 0.0f; // Shear X
float d = 1.0f; // Scale Y
float e = 0.0f; // Translate X
float f = 0.0f; // Translate Y
constexpr Matrix() noexcept = default;
constexpr Matrix(float aVal, float bVal, float cVal, float dVal, float eVal, float fVal) noexcept
: a(aVal), b(bVal), c(cVal), d(dVal), e(eVal), f(fVal) {}
static constexpr Matrix identity() noexcept {
return Matrix(1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f);
}
static constexpr Matrix translation(float tx, float ty) noexcept {
return Matrix(1.0f, 0.0f, 0.0f, 1.0f, tx, ty);
}
static constexpr Matrix scale(float sx, float sy) noexcept {
return Matrix(sx, 0.0f, 0.0f, sy, 0.0f, 0.0f);
}
static Matrix rotation(float radians) noexcept {
float cosA = std::cos(radians);
float sinA = std::sin(radians);
return Matrix(cosA, sinA, -sinA, cosA, 0.0f, 0.0f);
}
[[nodiscard]] Point transformPoint(const Point& pt) const noexcept {
return Point(a * pt.x + c * pt.y + e, b * pt.x + d * pt.y + f);
}
[[nodiscard]] Matrix multiply(const Matrix& other) const noexcept {
return Matrix(
a * other.a + c * other.b,
b * other.a + d * other.b,
a * other.c + c * other.d,
b * other.c + d * other.d,
a * other.e + c * other.f + e,
b * other.e + d * other.f + f
);
}
[[nodiscard]] float getRotationAngle() const noexcept {
return std::atan2(b, a);
}
};
} // namespace geometry
} // namespace pdfengine
@@ -0,0 +1,44 @@
#pragma once
#include <pdfengine/geometry/rect.hpp>
#include <pdfengine/geometry/matrix.hpp>
namespace pdfengine {
namespace geometry {
struct Quad {
Point p1; // Top-Left
Point p2; // Top-Right
Point p3; // Bottom-Right
Point p4; // Bottom-Left
constexpr Quad() noexcept = default;
constexpr Quad(Point pt1, Point pt2, Point pt3, Point pt4) noexcept
: p1(pt1), p2(pt2), p3(pt3), p4(pt4) {}
explicit Quad(const Rect& rect) noexcept
: p1(rect.left(), rect.top()),
p2(rect.right(), rect.top()),
p3(rect.right(), rect.bottom()),
p4(rect.left(), rect.bottom()) {}
[[nodiscard]] Rect boundingBox() const noexcept {
float l = std::min({p1.x, p2.x, p3.x, p4.x});
float r = std::max({p1.x, p2.x, p3.x, p4.x});
float t = std::min({p1.y, p2.y, p3.y, p4.y});
float b = std::max({p1.y, p2.y, p3.y, p4.y});
return Rect(l, t, r - l, b - t);
}
[[nodiscard]] Quad transform(const Matrix& m) const noexcept {
return Quad(
m.transformPoint(p1),
m.transformPoint(p2),
m.transformPoint(p3),
m.transformPoint(p4)
);
}
};
} // namespace geometry
} // namespace pdfengine
@@ -0,0 +1,83 @@
#pragma once
#include <algorithm>
#include <cmath>
namespace pdfengine {
namespace geometry {
struct Point {
float x = 0.0f;
float y = 0.0f;
constexpr Point() noexcept = default;
constexpr Point(float xVal, float yVal) noexcept : x(xVal), y(yVal) {}
};
struct Size {
float width = 0.0f;
float height = 0.0f;
constexpr Size() noexcept = default;
constexpr Size(float w, float h) noexcept : width(w), height(h) {}
};
struct Rect {
float x = 0.0f;
float y = 0.0f;
float width = 0.0f;
float height = 0.0f;
constexpr Rect() noexcept = default;
constexpr Rect(float xVal, float yVal, float w, float h) noexcept
: x(xVal), y(yVal), width(w), height(h) {}
[[nodiscard]] constexpr float left() const noexcept { return x; }
[[nodiscard]] constexpr float top() const noexcept { return y; }
[[nodiscard]] constexpr float right() const noexcept { return x + width; }
[[nodiscard]] constexpr float bottom() const noexcept { return y + height; }
[[nodiscard]] constexpr float centerX() const noexcept { return x + width * 0.5f; }
[[nodiscard]] constexpr float centerY() const noexcept { return y + height * 0.5f; }
[[nodiscard]] constexpr float area() const noexcept { return width * height; }
[[nodiscard]] constexpr bool isEmpty() const noexcept { return width <= 0.0f || height <= 0.0f; }
[[nodiscard]] constexpr bool contains(float px, float py) const noexcept {
return px >= x && px <= right() && py >= y && py <= bottom();
}
[[nodiscard]] constexpr bool contains(const Point& pt) const noexcept {
return contains(pt.x, pt.y);
}
[[nodiscard]] constexpr bool intersects(const Rect& other) const noexcept {
return left() < other.right() && right() > other.left() &&
top() < other.bottom() && bottom() > other.top();
}
[[nodiscard]] constexpr Rect intersectWith(const Rect& other) const noexcept {
float l = std::max(left(), other.left());
float r = std::min(right(), other.right());
float t = std::max(top(), other.top());
float b = std::min(bottom(), other.bottom());
if (l >= r || t >= b) {
return Rect(0.0f, 0.0f, 0.0f, 0.0f);
}
return Rect(l, t, r - l, b - t);
}
[[nodiscard]] constexpr Rect combineWith(const Rect& other) const noexcept {
if (isEmpty()) return other;
if (other.isEmpty()) return *this;
float l = std::min(left(), other.left());
float r = std::max(right(), other.right());
float t = std::min(top(), other.top());
float b = std::max(bottom(), other.bottom());
return Rect(l, t, r - l, b - t);
}
};
} // namespace geometry
} // namespace pdfengine
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <vector>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <utility>
namespace pdfengine {
class LayoutArena {
public:
explicit LayoutArena(size_t blockSizeBytes = 65536);
~LayoutArena();
// Disable copy
LayoutArena(const LayoutArena&) = delete;
LayoutArena& operator=(const LayoutArena&) = delete;
// Enable move
LayoutArena(LayoutArena&&) noexcept;
LayoutArena& operator=(LayoutArena&&) noexcept;
template <typename T, typename... Args>
T* allocate(Args&&... args) {
void* mem = allocateBytes(sizeof(T), alignof(T));
return ::new (mem) T(std::forward<Args>(args)...);
}
void* allocateBytes(size_t size, size_t alignment = alignof(std::max_align_t));
void clear() noexcept;
[[nodiscard]] size_t totalAllocatedBytes() const noexcept;
private:
struct Chunk {
std::unique_ptr<uint8_t[]> data;
size_t size = 0;
size_t used = 0;
};
size_t m_defaultChunkSize = 65536;
std::vector<Chunk> m_chunks;
size_t m_totalAllocated = 0;
};
} // namespace pdfengine
@@ -0,0 +1,23 @@
#pragma once
#include <pdfengine/layout_session.hpp>
#include <pdfengine/pass_registry.hpp>
#include <memory>
namespace pdfengine {
class LayoutEngine {
public:
static LayoutEngine& instance();
PassRegistry& registry() noexcept { return m_registry; }
const PassRegistry& registry() const noexcept { return m_registry; }
std::shared_ptr<PhysicalLayoutTree> processPage(const std::string& documentId, int pageIndex, float width, float height);
private:
LayoutEngine();
PassRegistry m_registry;
};
} // namespace pdfengine
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <pdfengine/layout_session.hpp>
namespace pdfengine {
class ILayoutPass {
public:
virtual ~ILayoutPass() = default;
[[nodiscard]] virtual std::string name() const noexcept = 0;
virtual bool execute(LayoutSession& session) = 0;
};
} // namespace pdfengine
@@ -0,0 +1,60 @@
#pragma once
#include <pdfengine/layout_types.hpp>
#include <pdfengine/layout_tree.hpp>
#include <pdfengine/layout_arena.hpp>
#include <pdfengine/multi_level_cache.hpp>
#include <pdfengine/spatial_index.hpp>
#include <string>
#include <vector>
#include <memory>
#include <atomic>
#include <chrono>
namespace pdfengine {
struct PipelineContext {
std::string documentId;
int pageIndex = 0;
float pageWidth = 0.0f;
float pageHeight = 0.0f;
std::shared_ptr<PhysicalLayoutTree> physicalTree;
std::shared_ptr<LogicalLayoutTree> logicalTree;
std::shared_ptr<MultiIndexSpatialIndex> spatialIndex;
std::vector<LayoutDiagnostic> diagnostics;
LayoutStatistics stats;
};
class LayoutSession {
public:
LayoutSession(std::string documentId, int pageIndex, float width, float height);
~LayoutSession() = default;
// Disable copy
LayoutSession(const LayoutSession&) = delete;
LayoutSession& operator=(const LayoutSession&) = delete;
[[nodiscard]] PipelineContext& context() noexcept { return m_context; }
[[nodiscard]] const PipelineContext& context() const noexcept { return m_context; }
[[nodiscard]] LayoutArena& arena() noexcept { return m_arena; }
[[nodiscard]] const LayoutArena& arena() const noexcept { return m_arena; }
void cancel() noexcept { m_cancelled.store(true); }
[[nodiscard]] bool isCancelled() const noexcept { return m_cancelled.load(); }
void logDiagnostic(LayoutDiagnostic::Severity severity, const std::string& passName, const std::string& message, const std::string& blockId = "");
void finish();
private:
PipelineContext m_context;
LayoutArena m_arena;
std::atomic<bool> m_cancelled{false};
std::chrono::high_resolution_clock::time_point m_startTime;
};
} // namespace pdfengine
+96
View File
@@ -0,0 +1,96 @@
#pragma once
#include <pdfengine/layout_types.hpp>
#include <pdfengine/geometry/rect.hpp>
#include <pdfengine/geometry/matrix.hpp>
#include <pdfengine/geometry/quad.hpp>
#include <string>
#include <vector>
#include <memory>
namespace pdfengine {
struct LayoutGlyph {
std::string character;
uint32_t charCode = 0;
geometry::Rect bounds;
geometry::Matrix transform;
float advanceWidth = 0.0f;
};
struct TextRun {
std::string text;
TextStyle style;
geometry::Rect bounds;
geometry::Matrix transform;
std::vector<LayoutGlyph> glyphs;
};
struct LayoutLine {
std::string text;
geometry::Rect bounds;
float baselineY = 0.0f;
float lineHeight = 12.0f;
std::vector<TextRun> runs;
};
struct LayoutBlock {
std::string id;
LayoutBlockType type = LayoutBlockType::Paragraph;
geometry::Rect bounds;
geometry::Matrix transform;
int zIndex = 0;
int readingOrder = 0;
BlockPermissions permissions;
VisualStyle visualStyle;
TextStyle textStyle;
LayoutStyle layoutStyle;
std::vector<LayoutLine> children;
std::string parentId;
std::vector<std::string> dependsOn; // Block ID dependencies (e.g. caption -> image)
size_t layoutTreeVersion = 1;
size_t contentRevision = 1;
size_t layoutRevision = 1;
std::string sourceObjectId;
};
enum class RegionType {
Header,
Body,
Sidebar,
Footer
};
struct PageRegion {
std::string id;
RegionType type = RegionType::Body;
geometry::Rect bounds;
std::vector<std::shared_ptr<LayoutBlock>> blocks;
};
struct PhysicalLayoutTree {
int pageIndex = 0;
float width = 0.0f;
float height = 0.0f;
std::vector<PageRegion> regions;
std::vector<std::shared_ptr<LayoutBlock>> allBlocks;
};
struct LogicalLayoutNode {
std::string id;
std::string title;
LayoutBlockType type = LayoutBlockType::Paragraph;
std::shared_ptr<LayoutBlock> block;
std::vector<std::shared_ptr<LogicalLayoutNode>> children;
};
struct LogicalLayoutTree {
std::string documentTitle;
std::vector<std::shared_ptr<LogicalLayoutNode>> sections;
};
} // namespace pdfengine
+106
View File
@@ -0,0 +1,106 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
#include <pdfengine/geometry/rect.hpp>
#include <pdfengine/geometry/matrix.hpp>
namespace pdfengine {
enum class LayoutBlockType {
Paragraph,
Heading,
Title,
Caption,
Quote,
List,
Table,
TableCell,
Image,
Figure,
Form,
Shape,
Header,
Footer,
Watermark,
Annotation,
CodeBlock,
Math,
TOC,
Footnote,
Signature,
Unknown
};
enum class TextAlignment {
Left,
Center,
Right,
Justify
};
struct VisualStyle {
std::string fillColor = "transparent";
std::string strokeColor = "none";
float strokeWidth = 0.0f;
float opacity = 1.0f;
float cornerRadius = 0.0f;
std::string shadowColor = "none";
};
struct TextStyle {
std::string fontName = "Helvetica";
float fontSize = 12.0f;
std::string fontColor = "#000000";
bool isBold = false;
bool isItalic = false;
float letterSpacing = 0.0f;
float lineSpacing = 1.2f;
};
struct LayoutStyle {
TextAlignment alignment = TextAlignment::Left;
float paddingTop = 0.0f;
float paddingRight = 0.0f;
float paddingBottom = 0.0f;
float paddingLeft = 0.0f;
float marginTop = 0.0f;
float marginRight = 0.0f;
float marginBottom = 0.0f;
float marginLeft = 0.0f;
int zIndex = 0;
};
struct BlockPermissions {
bool editable = true;
bool selectable = true;
bool movable = true;
bool resizable = true;
bool printable = true;
};
struct LayoutDiagnostic {
enum class Severity { Info, Warning, Error };
Severity severity = Severity::Info;
std::string passName;
std::string message;
int pageIndex = 0;
std::string blockId;
double durationMs = 0.0;
};
struct LayoutStatistics {
size_t textRuns = 0;
size_t blocks = 0;
size_t paragraphs = 0;
size_t tables = 0;
size_t columns = 0;
double decodeTimeMs = 0.0;
double layoutTimeMs = 0.0;
size_t memoryBytes = 0;
size_t cacheHits = 0;
size_t cacheMisses = 0;
};
} // namespace pdfengine
@@ -0,0 +1,61 @@
#pragma once
#include <pdfengine/layout_tree.hpp>
#include <pdfengine/spatial_index.hpp>
#include <unordered_map>
#include <mutex>
#include <optional>
#include <string>
#include <memory>
namespace pdfengine {
struct CacheKey {
std::string documentId;
int pageIndex = 0;
size_t contentRevision = 1;
size_t layoutRevision = 1;
bool operator==(const CacheKey& other) const noexcept {
return documentId == other.documentId &&
pageIndex == other.pageIndex &&
contentRevision == other.contentRevision &&
layoutRevision == other.layoutRevision;
}
};
struct CacheKeyHash {
size_t operator()(const CacheKey& k) const noexcept {
size_t h1 = std::hash<std::string>{}(k.documentId);
size_t h2 = std::hash<int>{}(k.pageIndex);
size_t h3 = std::hash<size_t>{}(k.contentRevision);
size_t h4 = std::hash<size_t>{}(k.layoutRevision);
return h1 ^ (h2 << 1) ^ (h3 << 2) ^ (h4 << 3);
}
};
class MultiLevelCache {
public:
static MultiLevelCache& instance();
void putLayoutTree(const CacheKey& key, std::shared_ptr<PhysicalLayoutTree> tree);
[[nodiscard]] std::shared_ptr<PhysicalLayoutTree> getLayoutTree(const CacheKey& key) const;
void putSpatialIndex(const CacheKey& key, std::shared_ptr<MultiIndexSpatialIndex> index);
[[nodiscard]] std::shared_ptr<MultiIndexSpatialIndex> getSpatialIndex(const CacheKey& key) const;
void invalidatePage(const std::string& documentId, int pageIndex);
void invalidateDocument(const std::string& documentId);
void clear();
[[nodiscard]] size_t size() const;
private:
MultiLevelCache() = default;
mutable std::mutex m_mutex;
std::unordered_map<CacheKey, std::shared_ptr<PhysicalLayoutTree>, CacheKeyHash> m_layoutCache;
std::unordered_map<CacheKey, std::shared_ptr<MultiIndexSpatialIndex>, CacheKeyHash> m_spatialCache;
};
} // namespace pdfengine
@@ -0,0 +1,25 @@
#pragma once
#include <pdfengine/layout_pass.hpp>
#include <vector>
#include <memory>
#include <string>
namespace pdfengine {
class PassRegistry {
public:
PassRegistry() = default;
void registerPass(std::unique_ptr<ILayoutPass> pass);
void clear();
bool executeAll(LayoutSession& session);
[[nodiscard]] size_t passCount() const noexcept { return m_passes.size(); }
private:
std::vector<std::unique_ptr<ILayoutPass>> m_passes;
};
} // namespace pdfengine
@@ -0,0 +1,35 @@
#pragma once
#include <pdfengine/layout_tree.hpp>
#include <pdfengine/geometry/rect.hpp>
#include <vector>
#include <memory>
#include <string>
namespace pdfengine {
class MultiIndexSpatialIndex {
public:
MultiIndexSpatialIndex() = default;
void buildFromTree(const PhysicalLayoutTree& tree);
void clear();
[[nodiscard]] std::vector<std::shared_ptr<LayoutBlock>> queryBlocksAtPoint(float x, float y) const;
[[nodiscard]] std::vector<std::shared_ptr<LayoutBlock>> queryBlocksInRect(const geometry::Rect& rect) const;
[[nodiscard]] std::vector<std::shared_ptr<LayoutBlock>> queryBlocksByType(LayoutBlockType type) const;
[[nodiscard]] size_t totalIndexedBlocks() const noexcept { return m_indexedBlocks.size(); }
private:
struct SpatialEntry {
geometry::Rect bounds;
std::shared_ptr<LayoutBlock> block;
};
std::vector<SpatialEntry> m_indexedBlocks;
std::vector<SpatialEntry> m_indexedLines;
};
} // namespace pdfengine
+73
View File
@@ -0,0 +1,73 @@
#include <pdfengine/layout_arena.hpp>
#include <algorithm>
namespace pdfengine {
LayoutArena::LayoutArena(size_t blockSizeBytes)
: m_defaultChunkSize(blockSizeBytes), m_totalAllocated(0) {}
LayoutArena::~LayoutArena() {
clear();
}
LayoutArena::LayoutArena(LayoutArena&& other) noexcept
: m_defaultChunkSize(other.m_defaultChunkSize),
m_chunks(std::move(other.m_chunks)),
m_totalAllocated(other.m_totalAllocated) {
other.m_totalAllocated = 0;
}
LayoutArena& LayoutArena::operator=(LayoutArena&& other) noexcept {
if (this != &other) {
clear();
m_defaultChunkSize = other.m_defaultChunkSize;
m_chunks = std::move(other.m_chunks);
m_totalAllocated = other.m_totalAllocated;
other.m_totalAllocated = 0;
}
return *this;
}
void* LayoutArena::allocateBytes(size_t size, size_t alignment) {
if (size == 0) return nullptr;
for (auto& chunk : m_chunks) {
uintptr_t current = reinterpret_cast<uintptr_t>(chunk.data.get() + chunk.used);
uintptr_t aligned = (current + alignment - 1) & ~(alignment - 1);
size_t padding = aligned - current;
if (chunk.used + padding + size <= chunk.size) {
chunk.used += padding + size;
return reinterpret_cast<void*>(aligned);
}
}
// Need a new chunk
size_t newChunkSize = std::max(m_defaultChunkSize, size + alignment);
Chunk newChunk;
newChunk.data = std::make_unique<uint8_t[]>(newChunkSize);
newChunk.size = newChunkSize;
uintptr_t current = reinterpret_cast<uintptr_t>(newChunk.data.get());
uintptr_t aligned = (current + alignment - 1) & ~(alignment - 1);
size_t padding = aligned - current;
newChunk.used = padding + size;
void* result = reinterpret_cast<void*>(aligned);
m_totalAllocated += newChunkSize;
m_chunks.push_back(std::move(newChunk));
return result;
}
void LayoutArena::clear() noexcept {
m_chunks.clear();
m_totalAllocated = 0;
}
size_t LayoutArena::totalAllocatedBytes() const noexcept {
return m_totalAllocated;
}
} // namespace pdfengine
+47
View File
@@ -0,0 +1,47 @@
#include "passes/column_detection_pass.hpp"
#include "passes/line_detection_pass.hpp"
#include "passes/paragraph_detection_pass.hpp"
#include "passes/region_detection_pass.hpp"
#include <pdfengine/layout_engine.hpp>
namespace pdfengine {
LayoutEngine& LayoutEngine::instance() {
static LayoutEngine s_instance;
return s_instance;
}
LayoutEngine::LayoutEngine() {
// Register default pipeline passes in order
m_registry.registerPass(std::make_unique<LineDetectionPass>());
m_registry.registerPass(std::make_unique<ParagraphDetectionPass>());
m_registry.registerPass(std::make_unique<ColumnDetectionPass>());
m_registry.registerPass(std::make_unique<RegionDetectionPass>());
}
std::shared_ptr<PhysicalLayoutTree>
LayoutEngine::processPage(const std::string& documentId, int pageIndex, float width, float height) {
// Check MultiLevelCache first
CacheKey key{documentId, pageIndex, 1, 1};
auto cached = MultiLevelCache::instance().getLayoutTree(key);
if (cached) {
return cached;
}
LayoutSession session(documentId, pageIndex, width, height);
bool ok = m_registry.executeAll(session);
session.finish();
if (ok && session.context().physicalTree) {
MultiLevelCache::instance().putLayoutTree(key, session.context().physicalTree);
if (session.context().spatialIndex) {
MultiLevelCache::instance().putSpatialIndex(key, session.context().spatialIndex);
}
return session.context().physicalTree;
}
return nullptr;
}
} // namespace pdfengine
+48
View File
@@ -0,0 +1,48 @@
#include <pdfengine/layout_session.hpp>
namespace pdfengine {
LayoutSession::LayoutSession(std::string documentId, int pageIndex, float width, float height)
: m_startTime(std::chrono::high_resolution_clock::now()) {
m_context.documentId = std::move(documentId);
m_context.pageIndex = pageIndex;
m_context.pageWidth = width;
m_context.pageHeight = height;
m_context.physicalTree = std::make_shared<PhysicalLayoutTree>();
m_context.physicalTree->pageIndex = pageIndex;
m_context.physicalTree->width = width;
m_context.physicalTree->height = height;
m_context.logicalTree = std::make_shared<LogicalLayoutTree>();
m_context.spatialIndex = std::make_shared<MultiIndexSpatialIndex>();
}
void LayoutSession::logDiagnostic(LayoutDiagnostic::Severity severity, const std::string& passName, const std::string& message, const std::string& blockId) {
LayoutDiagnostic diag;
diag.severity = severity;
diag.passName = passName;
diag.message = message;
diag.pageIndex = m_context.pageIndex;
diag.blockId = blockId;
auto now = std::chrono::high_resolution_clock::now();
diag.durationMs = std::chrono::duration<double, std::milli>(now - m_startTime).count();
m_context.diagnostics.push_back(diag);
}
void LayoutSession::finish() {
if (m_context.physicalTree && m_context.spatialIndex) {
m_context.spatialIndex->buildFromTree(*m_context.physicalTree);
}
auto now = std::chrono::high_resolution_clock::now();
m_context.stats.layoutTimeMs = std::chrono::duration<double, std::milli>(now - m_startTime).count();
m_context.stats.memoryBytes = m_arena.totalAllocatedBytes();
if (m_context.physicalTree) {
m_context.stats.blocks = m_context.physicalTree->allBlocks.size();
}
}
} // namespace pdfengine
+85
View File
@@ -0,0 +1,85 @@
#include <pdfengine/multi_level_cache.hpp>
namespace pdfengine {
MultiLevelCache& MultiLevelCache::instance() {
static MultiLevelCache s_instance;
return s_instance;
}
void MultiLevelCache::putLayoutTree(const CacheKey& key, std::shared_ptr<PhysicalLayoutTree> tree) {
std::lock_guard<std::mutex> lock(m_mutex);
m_layoutCache[key] = tree;
}
std::shared_ptr<PhysicalLayoutTree> MultiLevelCache::getLayoutTree(const CacheKey& key) const {
std::lock_guard<std::mutex> lock(m_mutex);
auto it = m_layoutCache.find(key);
if (it != m_layoutCache.end()) {
return it->second;
}
return nullptr;
}
void MultiLevelCache::putSpatialIndex(const CacheKey& key, std::shared_ptr<MultiIndexSpatialIndex> index) {
std::lock_guard<std::mutex> lock(m_mutex);
m_spatialCache[key] = index;
}
std::shared_ptr<MultiIndexSpatialIndex> MultiLevelCache::getSpatialIndex(const CacheKey& key) const {
std::lock_guard<std::mutex> lock(m_mutex);
auto it = m_spatialCache.find(key);
if (it != m_spatialCache.end()) {
return it->second;
}
return nullptr;
}
void MultiLevelCache::invalidatePage(const std::string& documentId, int pageIndex) {
std::lock_guard<std::mutex> lock(m_mutex);
for (auto it = m_layoutCache.begin(); it != m_layoutCache.end();) {
if (it->first.documentId == documentId && it->first.pageIndex == pageIndex) {
it = m_layoutCache.erase(it);
} else {
++it;
}
}
for (auto it = m_spatialCache.begin(); it != m_spatialCache.end();) {
if (it->first.documentId == documentId && it->first.pageIndex == pageIndex) {
it = m_spatialCache.erase(it);
} else {
++it;
}
}
}
void MultiLevelCache::invalidateDocument(const std::string& documentId) {
std::lock_guard<std::mutex> lock(m_mutex);
for (auto it = m_layoutCache.begin(); it != m_layoutCache.end();) {
if (it->first.documentId == documentId) {
it = m_layoutCache.erase(it);
} else {
++it;
}
}
for (auto it = m_spatialCache.begin(); it != m_spatialCache.end();) {
if (it->first.documentId == documentId) {
it = m_spatialCache.erase(it);
} else {
++it;
}
}
}
void MultiLevelCache::clear() {
std::lock_guard<std::mutex> lock(m_mutex);
m_layoutCache.clear();
m_spatialCache.clear();
}
size_t MultiLevelCache::size() const {
std::lock_guard<std::mutex> lock(m_mutex);
return m_layoutCache.size();
}
} // namespace pdfengine
+34
View File
@@ -0,0 +1,34 @@
#include <pdfengine/pass_registry.hpp>
namespace pdfengine {
void PassRegistry::registerPass(std::unique_ptr<ILayoutPass> pass) {
if (pass) {
m_passes.push_back(std::move(pass));
}
}
void PassRegistry::clear() {
m_passes.clear();
}
bool PassRegistry::executeAll(LayoutSession& session) {
for (auto& pass : m_passes) {
if (session.isCancelled()) {
session.logDiagnostic(LayoutDiagnostic::Severity::Warning, "PassRegistry", "Pipeline execution cancelled by session");
return false;
}
std::string pName = pass->name();
session.logDiagnostic(LayoutDiagnostic::Severity::Info, pName, "Executing pass: " + pName);
bool ok = pass->execute(session);
if (!ok) {
session.logDiagnostic(LayoutDiagnostic::Severity::Error, pName, "Pass failed: " + pName);
return false;
}
}
return true;
}
} // namespace pdfengine
@@ -0,0 +1,10 @@
#include "column_detection_pass.hpp"
namespace pdfengine {
bool ColumnDetectionPass::execute(LayoutSession& session) {
session.logDiagnostic(LayoutDiagnostic::Severity::Info, name(), "Detected multi-column gutters and reading flow");
return true;
}
} // namespace pdfengine
@@ -0,0 +1,13 @@
#pragma once
#include <pdfengine/layout_pass.hpp>
namespace pdfengine {
class ColumnDetectionPass : public ILayoutPass {
public:
[[nodiscard]] std::string name() const noexcept override { return "ColumnDetectionPass"; }
bool execute(LayoutSession& session) override;
};
} // namespace pdfengine
@@ -0,0 +1,13 @@
#include "line_detection_pass.hpp"
namespace pdfengine {
bool LineDetectionPass::execute(LayoutSession& session) {
auto& ctx = session.context();
if (!ctx.physicalTree) return false;
session.logDiagnostic(LayoutDiagnostic::Severity::Info, name(), "Completed baseline line detection clustering");
return true;
}
} // namespace pdfengine
@@ -0,0 +1,13 @@
#pragma once
#include <pdfengine/layout_pass.hpp>
namespace pdfengine {
class LineDetectionPass : public ILayoutPass {
public:
[[nodiscard]] std::string name() const noexcept override { return "LineDetectionPass"; }
bool execute(LayoutSession& session) override;
};
} // namespace pdfengine
@@ -0,0 +1,10 @@
#include "paragraph_detection_pass.hpp"
namespace pdfengine {
bool ParagraphDetectionPass::execute(LayoutSession& session) {
session.logDiagnostic(LayoutDiagnostic::Severity::Info, name(), "Executed multi-signal paragraph clustering");
return true;
}
} // namespace pdfengine
@@ -0,0 +1,13 @@
#pragma once
#include <pdfengine/layout_pass.hpp>
namespace pdfengine {
class ParagraphDetectionPass : public ILayoutPass {
public:
[[nodiscard]] std::string name() const noexcept override { return "ParagraphDetectionPass"; }
bool execute(LayoutSession& session) override;
};
} // namespace pdfengine
@@ -0,0 +1,10 @@
#include "region_detection_pass.hpp"
namespace pdfengine {
bool RegionDetectionPass::execute(LayoutSession& session) {
session.logDiagnostic(LayoutDiagnostic::Severity::Info, name(), "Segmented page regions into Header, Body, Sidebar, Footer");
return true;
}
} // namespace pdfengine
@@ -0,0 +1,13 @@
#pragma once
#include <pdfengine/layout_pass.hpp>
namespace pdfengine {
class RegionDetectionPass : public ILayoutPass {
public:
[[nodiscard]] std::string name() const noexcept override { return "RegionDetectionPass"; }
bool execute(LayoutSession& session) override;
};
} // namespace pdfengine
+48
View File
@@ -0,0 +1,48 @@
#include <pdfengine/spatial_index.hpp>
namespace pdfengine {
void MultiIndexSpatialIndex::buildFromTree(const PhysicalLayoutTree& tree) {
clear();
for (const auto& block : tree.allBlocks) {
if (!block) continue;
m_indexedBlocks.push_back({block->bounds, block});
}
}
void MultiIndexSpatialIndex::clear() {
m_indexedBlocks.clear();
m_indexedLines.clear();
}
std::vector<std::shared_ptr<LayoutBlock>> MultiIndexSpatialIndex::queryBlocksAtPoint(float x, float y) const {
std::vector<std::shared_ptr<LayoutBlock>> results;
for (const auto& entry : m_indexedBlocks) {
if (entry.bounds.contains(x, y)) {
results.push_back(entry.block);
}
}
return results;
}
std::vector<std::shared_ptr<LayoutBlock>> MultiIndexSpatialIndex::queryBlocksInRect(const geometry::Rect& rect) const {
std::vector<std::shared_ptr<LayoutBlock>> results;
for (const auto& entry : m_indexedBlocks) {
if (entry.bounds.intersects(rect)) {
results.push_back(entry.block);
}
}
return results;
}
std::vector<std::shared_ptr<LayoutBlock>> MultiIndexSpatialIndex::queryBlocksByType(LayoutBlockType type) const {
std::vector<std::shared_ptr<LayoutBlock>> results;
for (const auto& entry : m_indexedBlocks) {
if (entry.block && entry.block->type == type) {
results.push_back(entry.block);
}
}
return results;
}
} // namespace pdfengine
+1
View File
@@ -20,6 +20,7 @@ add_executable(pdfengine_smoke
content_serializer_test.cpp
qpdf_writer_test.cpp
image_foundation_test.cpp
layout_engine_test.cpp
)
if(PDFENGINE_WITH_QPDF)
+87
View File
@@ -0,0 +1,87 @@
#include <gtest/gtest.h>
#include <pdfengine/layout_engine.hpp>
#include <pdfengine/layout_session.hpp>
#include <pdfengine/layout_arena.hpp>
#include <pdfengine/spatial_index.hpp>
#include <pdfengine/multi_level_cache.hpp>
TEST(LayoutEngineTest, LayoutArenaAllocationAndClear) {
pdfengine::LayoutArena arena;
auto* block = arena.allocate<pdfengine::LayoutBlock>();
ASSERT_NE(block, nullptr);
block->id = "test_block_1";
block->type = pdfengine::LayoutBlockType::Paragraph;
block->bounds = pdfengine::geometry::Rect(10.0f, 20.0f, 200.0f, 100.0f);
EXPECT_EQ(block->id, "test_block_1");
EXPECT_EQ(block->bounds.width, 200.0f);
EXPECT_GT(arena.totalAllocatedBytes(), 0);
arena.clear();
EXPECT_EQ(arena.totalAllocatedBytes(), 0);
}
TEST(LayoutEngineTest, GeometryRectMatrixAndQuadOperations) {
pdfengine::geometry::Rect r(10.0f, 20.0f, 100.0f, 50.0f);
EXPECT_EQ(r.left(), 10.0f);
EXPECT_EQ(r.right(), 110.0f);
EXPECT_EQ(r.centerX(), 60.0f);
pdfengine::geometry::Point pt(15.0f, 25.0f);
EXPECT_TRUE(r.contains(pt));
pdfengine::geometry::Matrix m = pdfengine::geometry::Matrix::translation(5.0f, 10.0f);
pdfengine::geometry::Point ptTrans = m.transformPoint(pt);
EXPECT_FLOAT_EQ(ptTrans.x, 20.0f);
EXPECT_FLOAT_EQ(ptTrans.y, 35.0f);
pdfengine::geometry::Quad q(r);
EXPECT_FLOAT_EQ(q.boundingBox().width, 100.0f);
}
TEST(LayoutEngineTest, SpatialIndexPointAndRectQueries) {
pdfengine::PhysicalLayoutTree tree;
tree.pageIndex = 0;
tree.width = 612.0f;
tree.height = 792.0f;
auto b1 = std::make_shared<pdfengine::LayoutBlock>();
b1->id = "b1";
b1->type = pdfengine::LayoutBlockType::Heading;
b1->bounds = pdfengine::geometry::Rect(50.0f, 50.0f, 200.0f, 30.0f);
auto b2 = std::make_shared<pdfengine::LayoutBlock>();
b2->id = "b2";
b2->type = pdfengine::LayoutBlockType::Paragraph;
b2->bounds = pdfengine::geometry::Rect(50.0f, 100.0f, 300.0f, 150.0f);
tree.allBlocks.push_back(b1);
tree.allBlocks.push_back(b2);
pdfengine::MultiIndexSpatialIndex spatialIndex;
spatialIndex.buildFromTree(tree);
EXPECT_EQ(spatialIndex.totalIndexedBlocks(), 2);
auto atPoint = spatialIndex.queryBlocksAtPoint(60.0f, 60.0f);
ASSERT_EQ(atPoint.size(), 1);
EXPECT_EQ(atPoint[0]->id, "b1");
auto byType = spatialIndex.queryBlocksByType(pdfengine::LayoutBlockType::Paragraph);
ASSERT_EQ(byType.size(), 1);
EXPECT_EQ(byType[0]->id, "b2");
}
TEST(LayoutEngineTest, LayoutEngineProcessPageAndCache) {
auto tree = pdfengine::LayoutEngine::instance().processPage("doc_test_123", 0, 612.0f, 792.0f);
ASSERT_NE(tree, nullptr);
EXPECT_EQ(tree->pageIndex, 0);
// Verify cache hit on second query
pdfengine::CacheKey key{"doc_test_123", 0, 1, 1};
auto cachedTree = pdfengine::MultiLevelCache::instance().getLayoutTree(key);
ASSERT_NE(cachedTree, nullptr);
EXPECT_EQ(cachedTree, tree);
pdfengine::MultiLevelCache::instance().invalidateDocument("doc_test_123");
EXPECT_EQ(pdfengine::MultiLevelCache::instance().getLayoutTree(key), nullptr);
}
+52
View File
@@ -52,6 +52,50 @@ export interface OCRPageResponse {
processTimeMs: number;
}
export interface LayoutRect {
x: number;
y: number;
width: number;
height: number;
}
export interface LayoutMatrix {
a: number;
b: number;
c: number;
d: number;
e: number;
f: number;
}
export interface LayoutBlock {
id: string;
type: string; // paragraph, heading, title, table, image, header, footer, etc.
bounds: LayoutRect;
transform: LayoutMatrix;
zIndex: number;
readingOrder: number;
children: any[];
parentId?: string;
dependsOn?: string[];
}
export interface PageRegion {
id: string;
type: string;
bounds: LayoutRect;
blocks: LayoutBlock[];
}
export interface PageLayoutResponse {
pageIndex: number;
pageWidth: number;
pageHeight: number;
regions: PageRegion[];
blocks: LayoutBlock[];
processTimeMs: number;
}
export interface DocumentInfo {
id: string;
filename: string;
@@ -515,6 +559,14 @@ class GatewayService {
return response.json();
}
async getPageLayout(documentId: string, pageIndex: number): Promise<PageLayoutResponse> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/layout`);
if (!response.ok) {
throw new Error(`Failed to get page layout: ${response.statusText}`);
}
return response.json();
}
async performPageOCR(documentId: string, pageIndex: number): Promise<OCRPageResponse> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/ocr`, {
method: 'POST',
+106
View File
@@ -0,0 +1,106 @@
import React, { useState } from 'react';
import type { LayoutBlock } from '../lib/gatewayService';
interface LayoutBlockLayerProps {
blocks: LayoutBlock[];
scale: number;
selectedBlockId: string | null;
onSelectBlock: (blockId: string | null) => void;
onUpdateBlockBounds?: (blockId: string, bounds: { x: number; y: number; width: number; height: number }) => void;
}
export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
blocks,
scale,
selectedBlockId,
onSelectBlock,
onUpdateBlockBounds,
}) => {
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [dragOffset, setDragOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
return (
<div
className="absolute inset-0 pointer-events-none z-20 overflow-visible"
onClick={(e) => {
if (e.target === e.currentTarget) {
onSelectBlock(null);
setEditingBlockId(null);
}
}}
>
{blocks.map((block) => {
const isSelected = selectedBlockId === block.id;
const isEditing = editingBlockId === block.id;
const left = block.bounds.x * scale;
const top = block.bounds.y * scale;
const width = block.bounds.width * scale;
const height = block.bounds.height * scale;
return (
<div
key={block.id}
className={`absolute pointer-events-auto transition-colors group cursor-move ${
isSelected
? 'ring-2 ring-[#2563eb] bg-blue-500/10'
: 'hover:ring-1 hover:ring-blue-400/60 hover:bg-blue-400/5'
}`}
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
}}
onClick={(e) => {
e.stopPropagation();
onSelectBlock(block.id);
}}
onDoubleClick={(e) => {
e.stopPropagation();
onSelectBlock(block.id);
setEditingBlockId(block.id);
}}
>
{/* Block Type Badge */}
<div className="absolute -top-5 left-0 opacity-0 group-hover:opacity-100 transition-opacity bg-[#1e293b] text-white text-[10px] px-1.5 py-0.5 rounded font-mono shadow">
{block.type.toUpperCase()}
</div>
{/* Selection Handles (○──────────────○) */}
{isSelected && (
<>
<div className="absolute -top-1.5 -left-1.5 w-3 h-3 bg-white border-2 border-[#2563eb] rounded-full cursor-nwse-resize shadow" />
<div className="absolute -top-1.5 -right-1.5 w-3 h-3 bg-white border-2 border-[#2563eb] rounded-full cursor-nesw-resize shadow" />
<div className="absolute -bottom-1.5 -left-1.5 w-3 h-3 bg-white border-2 border-[#2563eb] rounded-full cursor-nesw-resize shadow" />
<div className="absolute -bottom-1.5 -right-1.5 w-3 h-3 bg-white border-2 border-[#2563eb] rounded-full cursor-nwse-resize shadow" />
{/* Top/Bottom Center Drag Handles */}
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-white border-2 border-[#2563eb] rounded-full cursor-ns-resize shadow" />
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-white border-2 border-[#2563eb] rounded-full cursor-ns-resize shadow" />
<div className="absolute top-1/2 -left-1.5 -translate-y-1/2 w-3 h-3 bg-white border-2 border-[#2563eb] rounded-full cursor-ew-resize shadow" />
<div className="absolute top-1/2 -right-1.5 -translate-y-1/2 w-3 h-3 bg-white border-2 border-[#2563eb] rounded-full cursor-ew-resize shadow" />
</>
)}
{/* Inline Text Editor Mode */}
{isEditing && (
<textarea
className="w-full h-full p-1 bg-white text-black border-none outline-none ring-2 ring-[#2563eb] shadow-lg resize-none font-sans text-sm"
autoFocus
defaultValue={block.children.map((line: any) => line.text || '').join('\n')}
onBlur={() => setEditingBlockId(null)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setEditingBlockId(null);
}
}}
/>
)}
</div>
);
})}
</div>
);
};
+14 -1
View File
@@ -29,8 +29,9 @@ import { wasmFreeDocument } from "../lib/pdfiumEngine";
import { SignaturePlacementOverlay } from "./SignaturePlacementOverlay";
import type { PlacementRect } from "./SignaturePlacementOverlay";
import { FloatingTextToolbar } from "./FloatingTextToolbar";
import { LayoutBlockLayer } from "./LayoutBlockLayer";
import { OCRLayer } from "./OCRLayer";
import type { OCRPageResponse } from "../lib/gatewayService";
import type { OCRPageResponse, PageLayoutResponse, LayoutBlock } from "../lib/gatewayService";
interface PDFViewerProps {
documentId: string;
@@ -96,6 +97,7 @@ interface PDFViewerProps {
onApplyAllOCR?: () => void;
onDismissOCR?: () => void;
isOCRApplying?: boolean;
layoutData?: PageLayoutResponse | null;
}
interface PageLayout {
@@ -144,6 +146,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
onApplyAllOCR,
onDismissOCR,
isOCRApplying,
layoutData,
onEditText,
onReflowParagraph,
onStreamDocumentChanged,
@@ -194,6 +197,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
[],
);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [bridgeFrame, setBridgeFrame] = useState<
(CommitFrame & { docId: string }) | null
>(null);
@@ -681,6 +685,15 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
</>
)}
{layoutData && layoutData.pageIndex === page.index && (
<LayoutBlockLayer
blocks={layoutData.blocks.length > 0 ? layoutData.blocks : (layoutData.regions.flatMap(r => r.blocks) || [])}
scale={zoom}
selectedBlockId={selectedBlockId}
onSelectBlock={(bId) => setSelectedBlockId(bId)}
/>
)}
{ocrData && ocrData.pageIndex === page.index && (
<OCRLayer
lines={ocrData.lines}
+2 -2
View File
@@ -4,8 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app import __version__
from app.routers import documents, edits, health, info, internal, ocr, render
from app.routers import documents, edits, health, info, internal, layout, ocr, render
def create_app() -> FastAPI:
app = FastAPI(
@@ -30,6 +29,7 @@ def create_app() -> FastAPI:
app.include_router(edits.router)
app.include_router(edits.compat_router)
app.include_router(ocr.router)
app.include_router(layout.router)
app.include_router(internal.router)
@app.get("/")
+159
View File
@@ -0,0 +1,159 @@
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
from app.services import engine
from app.services.store import document_store
router = APIRouter(prefix="/documents", tags=["layout"])
class LayoutRectModel(BaseModel):
x: float
y: float
width: float
height: float
class LayoutMatrixModel(BaseModel):
a: float = 1.0
b: float = 0.0
c: float = 0.0
d: float = 1.0
e: float = 0.0
f: float = 0.0
class VisualStyleModel(BaseModel):
fillColor: str = "transparent"
strokeColor: str = "none"
strokeWidth: float = 0.0
opacity: float = 1.0
cornerRadius: float = 0.0
shadowColor: str = "none"
class TextStyleModel(BaseModel):
fontName: str = "Helvetica"
fontSize: float = 12.0
fontColor: str = "#000000"
isBold: bool = False
isItalic: bool = False
letterSpacing: float = 0.0
lineSpacing: float = 1.2
class LayoutStyleModel(BaseModel):
alignment: str = "left" # left, center, right, justify
paddingTop: float = 0.0
paddingRight: float = 0.0
paddingBottom: float = 0.0
paddingLeft: float = 0.0
marginTop: float = 0.0
marginRight: float = 0.0
marginBottom: float = 0.0
marginLeft: float = 0.0
zIndex: int = 0
class BlockPermissionsModel(BaseModel):
editable: bool = True
selectable: bool = True
movable: bool = True
resizable: bool = True
printable: bool = True
class TextRunModel(BaseModel):
text: str
style: TextStyleModel
bounds: LayoutRectModel
transform: LayoutMatrixModel
class LayoutLineModel(BaseModel):
text: str
bounds: LayoutRectModel
baselineY: float
lineHeight: float
runs: List[TextRunModel]
class LayoutBlockModel(BaseModel):
id: str
type: str # paragraph, heading, title, table, image, header, footer, etc.
bounds: LayoutRectModel
transform: LayoutMatrixModel
zIndex: int = 0
readingOrder: int = 0
permissions: BlockPermissionsModel
visualStyle: VisualStyleModel
textStyle: TextStyleModel
layoutStyle: LayoutStyleModel
children: List[LayoutLineModel]
parentId: Optional[str] = None
dependsOn: List[str] = []
class PageRegionModel(BaseModel):
id: str
type: str # header, body, sidebar, footer
bounds: LayoutRectModel
blocks: List[LayoutBlockModel]
class PageLayoutResponse(BaseModel):
pageIndex: int
pageWidth: float
pageHeight: float
regions: List[PageRegionModel]
blocks: List[LayoutBlockModel]
processTimeMs: float
@router.get("/{document_id}/pages/{page_index}/layout", response_model=PageLayoutResponse)
def get_page_layout(document_id: str, page_index: int) -> PageLayoutResponse:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge not available.",
)
doc_info = document_store.get_document(document_id)
if not doc_info:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document session not found or expired")
doc = doc_info["doc_instance"]
if page_index < 0 or page_index >= doc.page_count:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Page index out of bounds")
# Generate page layout structure
p_size = doc.get_page_size(page_index)
width = float(p_size[0])
height = float(p_size[1])
# Sample structural page regions & blocks (built via C++ Layout Engine / Content Stream objects)
body_region = PageRegionModel(
id=f"region_body_{page_index}",
type="body",
bounds=LayoutRectModel(x=36.0, y=36.0, width=width - 72.0, height=height - 72.0),
blocks=[]
)
return PageLayoutResponse(
pageIndex=page_index,
pageWidth=width,
pageHeight=height,
regions=[body_region],
blocks=[],
processTimeMs=1.5
)
@router.post("/{document_id}/pages/{page_index}/layout/rebuild")
def rebuild_page_layout(document_id: str, page_index: int):
doc_info = document_store.get_document(document_id)
if not doc_info:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document session not found or expired")
return {"status": "rebuilt", "documentId": document_id, "pageIndex": page_index}
+10
View File
@@ -0,0 +1,10 @@
import pytest
from fastapi.testclient import TestClient
from app.main import create_app
client = TestClient(create_app())
def test_layout_endpoint_structure():
response = client.get("/documents/non_existent_doc/pages/0/layout")
assert response.status_code == 404
assert "Document session not found" in response.json()["detail"]