fix
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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("/")
|
||||
|
||||
@@ -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}
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user