fix: code cleanup

This commit is contained in:
Furqan-14
2026-06-22 15:18:47 +05:30
parent f1b427d66e
commit e8620947a5
141 changed files with 554 additions and 1735 deletions
+1 -8
View File
@@ -1,10 +1,3 @@
// libFuzzer entry point: drive the full load → inspect → render → select path
// with arbitrary bytes, so the fuzzer can find crashes, OOMs, and UB in the
// PDF parsing and rendering code.
//
// Build with a Clang toolchain via the `fuzz-linux` preset (see engine/fuzz/
// README.md). Every operation is wrapped so a clean error never aborts the run —
// only a real crash (caught by the sanitizer) should stop the fuzzer.
#include "pdfengine/hardened_limits.h"
#include "pdfengine/pdf_document.hpp"
@@ -29,7 +22,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
(void)d.metadata();
(void)d.extractOutline();
const int limit = pages < 3 ? pages : 3; // bound work per input
const int limit = pages < 3 ? pages : 3;
for (int i = 0; i < limit; ++i) {
auto page = d.getPage(i);
if (!page) continue;
+1 -3
View File
@@ -27,11 +27,9 @@ public:
double numberValue = 0.0;
bool boolValue = false;
// We use a vector of shared_ptr for recursive data structures so the node is easily copyable/movable
std::vector<std::shared_ptr<AstNode>> arrayItems;
std::unordered_map<std::string, std::shared_ptr<AstNode>> dictItems;
// Constructors for convenience
AstNode() = default;
explicit AstNode(AstNodeType t) : type(t) {}
};
@@ -41,4 +39,4 @@ struct Operation {
std::vector<std::shared_ptr<AstNode>> operands;
};
} // namespace pdfengine
}
+4 -8
View File
@@ -32,12 +32,10 @@ class TextObject : public ContentObject {
public:
ContentObjectType getType() const override { return ContentObjectType::Text; }
std::string text; // The decoded text string
std::string fontName; // Font resource name (e.g. "F1")
double fontSize = 0.0; // Font size
std::string text;
std::string fontName;
double fontSize = 0.0;
// Text Transformation Matrix (a, b, c, d, e, f)
// Default is identity matrix: [1 0 0 1 0 0]
double tm[6] = {1.0, 0.0, 0.0, 1.0, 0.0, 0.0};
};
@@ -53,10 +51,8 @@ public:
int bitsPerComponent = 8;
bool hasSoftMask = false;
// Decoded raw pixels (RGBA format for Skia)
std::vector<uint8_t> pixelData;
// The Current Transformation Matrix (CTM) at the time the 'Do' operator was invoked
Matrix transform;
};
@@ -75,4 +71,4 @@ public:
Matrix transform;
};
} // namespace pdfengine
}
+3 -11
View File
@@ -5,12 +5,6 @@
namespace pdfengine {
/// Result of extracting a raw PDF content stream from a page.
/// rawContent — bytes as-found in the PDF (may be compressed)
/// decodedContent — after applying all /Filter chains (FlateDecode, etc.)
/// pageIndex — 0-based page index
/// filters — list of filter names applied, e.g. {"FlateDecode"}
/// compressed — true if at least one filter was applied
struct ExtractedStream {
std::string rawContent;
std::string decodedContent;
@@ -20,16 +14,14 @@ struct ExtractedStream {
bool multiStream = false;
};
/// Verifies structural integrity of a decoded content stream.
/// Returns true if all of: BT, ET, Tf, Tj/TJ are present.
struct StreamVerification {
bool hasBT = false;
bool hasET = false;
bool hasTf = false;
bool hasTj = false; // Tj or TJ
bool multiStream = false; // page had multiple /Contents streams
bool hasTj = false;
bool multiStream = false;
};
StreamVerification verifyContentStream(const ExtractedStream& stream);
} // namespace pdfengine
}
+1 -11
View File
@@ -10,13 +10,11 @@ namespace pdfengine {
class CommandVisitor;
// Base class for all drawing commands
struct Command {
virtual ~Command() = default;
virtual void accept(CommandVisitor& visitor) const = 0;
};
// --- Specific Command Types ---
struct SaveStateCommand : public Command {
void accept(CommandVisitor& visitor) const override;
@@ -41,7 +39,6 @@ struct FillRectCommand : public Command {
struct DrawTextCommand : public Command {
std::string text;
float x, y;
// We would eventually have a font reference here too
DrawTextCommand(std::string text, float x, float y) : text(std::move(text)), x(x), y(y) {}
void accept(CommandVisitor& visitor) const override;
};
@@ -69,9 +66,7 @@ struct DrawImageCommand : public Command {
};
// --- Visitor Interface ---
// The visitor interface that the renderer (or replay engine) implements
class CommandVisitor {
public:
virtual ~CommandVisitor() = default;
@@ -85,20 +80,15 @@ public:
virtual void visit(const DrawImageCommand& cmd) = 0;
};
// --- Display List Container ---
// A container that stores a sequence of drawing commands.
class DisplayList {
public:
DisplayList() = default;
// Add commands directly
void addCommand(std::unique_ptr<Command> cmd);
// Replay the commands to a visitor (renderer)
void replay(CommandVisitor& visitor) const;
// Helper methods to easily append common commands
void saveState();
void restoreState();
void setTransform(const Matrix& m);
@@ -115,4 +105,4 @@ private:
std::vector<std::unique_ptr<Command>> m_commands;
};
} // namespace pdfengine
}
+2 -18
View File
@@ -5,10 +5,6 @@
namespace pdfengine {
// 2D Affine Transformation Matrix (3x3 matrix optimized for 2D)
// [ a b 0 ]
// [ c d 0 ]
// [ e f 1 ]
struct Matrix {
float a = 1.0f, b = 0.0f;
float c = 0.0f, d = 1.0f;
@@ -18,10 +14,8 @@ struct Matrix {
Matrix(float a, float b, float c, float d, float e, float f)
: a(a), b(b), c(c), d(d), e(e), f(f) {}
// Multiply this matrix by another matrix
[[nodiscard]] Matrix multiply(const Matrix& other) const noexcept;
// Transform a 2D point using this matrix
void transform(float& x, float& y) const noexcept;
};
@@ -31,32 +25,22 @@ struct Color {
float b = 0.0f;
};
// Represents the current graphics state in a PDF document
struct GraphicsState {
Matrix ctm; // Current Transformation Matrix
Matrix ctm;
Color fillColor;
Color strokeColor;
float lineWidth = 1.0f;
// In the future, this will also hold:
// - Clipping paths
// - Font state (current font, font size)
// - Dash patterns
// - Line cap/join styles
};
// Manages the q/Q stack of graphics states
class GraphicsStateStack {
public:
GraphicsStateStack();
// Corresponds to the 'q' operator (save graphics state)
void push();
// Corresponds to the 'Q' operator (restore graphics state)
void pop();
// Access the current active graphics state
[[nodiscard]] GraphicsState& current();
[[nodiscard]] const GraphicsState& current() const;
@@ -64,4 +48,4 @@ private:
std::vector<GraphicsState> m_stack;
};
} // namespace pdfengine
}
+3 -27
View File
@@ -1,15 +1,3 @@
// Resource limits for hardening against malicious / malformed PDFs.
//
// These guard the allocation-sizing arithmetic in the load and render paths
// against integer overflow and pathological out-of-memory inputs (a 2-billion-pt
// page, a million-page document, a multi-gigabyte raster). The ceilings are set
// far above anything a legitimate document needs, so enforcing them never
// rejects real files — they exist purely to turn "crash / OOM" into a clean,
// recoverable error, which is exactly what a fuzzer needs to make progress.
//
// Header-only and dependency-free so the fuzz harness and the engine share one
// source of truth.
#ifndef PDFENGINE_HARDENED_LIMITS_H
#define PDFENGINE_HARDENED_LIMITS_H
@@ -17,33 +5,21 @@
namespace pdfengine::limits {
// Largest input document we will even attempt to parse (1 GiB).
inline constexpr std::uint64_t kMaxDocumentBytes = 1ull << 30;
// PDF hard-caps a page at 14,400 user units (200 in) per side; allow a very
// generous multiple of that to tolerate odd-but-real documents.
inline constexpr double kMaxPageDimensionPt = 200'000.0; // ~2,777 inches
inline constexpr double kMaxPageDimensionPt = 200'000.0;
// No legitimate document has this many pages; stops runaway iteration.
inline constexpr int kMaxPageCount = 100'000;
// MAX_OBJECTS: ceiling on the number of content objects on a single page. Guards
// against content-stream "object bombs" that would explode parsing/rendering
// time and memory. No real page comes near this.
inline constexpr int kMaxObjects = 5'000'000;
// Cap a single rasterised page at ~256 megapixels (≈1 GiB at 4 bytes/px). At
// 96 dpi that is roughly a 16k × 16k page — well beyond any real render.
inline constexpr std::int64_t kMaxRasterPixels = 256ll * 1024 * 1024;
// True if a raw page size (in points) is sane to render.
inline constexpr bool pageDimensionsOk(double widthPt, double heightPt) noexcept {
return widthPt > 0.0 && heightPt > 0.0 && widthPt <= kMaxPageDimensionPt &&
heightPt <= kMaxPageDimensionPt;
}
// True if a target raster (in pixels) fits the pixel budget without overflowing
// the width*height*4 byte computation.
inline constexpr bool rasterSizeOk(std::int64_t widthPx, std::int64_t heightPx) noexcept {
if (widthPx <= 0 || heightPx <= 0) return false;
if (widthPx > kMaxRasterPixels || heightPx > kMaxRasterPixels) return false;
@@ -62,6 +38,6 @@ inline constexpr bool objectCountOk(int objects) noexcept {
return objects >= 0 && objects <= kMaxObjects;
}
} // namespace pdfengine::limits
}
#endif // PDFENGINE_HARDENED_LIMITS_H
#endif
+3 -5
View File
@@ -5,7 +5,6 @@
namespace pdfengine {
// Basic color spaces we might encounter
enum class ColorSpace {
DeviceGray,
DeviceRGB,
@@ -13,12 +12,11 @@ enum class ColorSpace {
Indexed
};
// Holds decoded image data ready for the display list (typically RGBA)
struct ImageInfo {
int width = 0;
int height = 0;
int channels = 4; // 4 = RGBA
std::vector<uint8_t> pixelData; // Decoded raw pixels
int channels = 4;
std::vector<uint8_t> pixelData;
};
} // namespace pdfengine
}
+2 -5
View File
@@ -4,13 +4,11 @@
namespace pdfengine {
// Basic point structure
struct Point {
float x = 0.0f;
float y = 0.0f;
};
// Represents a 2D vector path constructed from basic drawing commands.
class Path {
public:
enum class Verb {
@@ -22,7 +20,7 @@ public:
struct Segment {
Verb verb;
Point points[3]; // Up to 3 points depending on verb (e.g., Cubic bezier)
Point points[3];
};
Path() = default;
@@ -43,7 +41,6 @@ public:
m_segments.push_back({Verb::Close, {{}, {}, {}}});
}
// Helper for 're' (rectangle) operator
void addRect(float x, float y, float w, float h) {
moveTo(x, y);
lineTo(x + w, y);
@@ -63,4 +60,4 @@ private:
std::vector<Segment> m_segments;
};
} // namespace pdfengine
}
+1 -11
View File
@@ -1,8 +1,3 @@
// pdfengine — public umbrella header for the PDF SDK core.
//
// Phase 0 surface only: version + build introspection. The real document API
// (PdfDocument / PdfPage) is frozen at Gate G0b and added in Phase 1 — see
// pdf_document.hpp.
#pragma once
#include <pdfengine/version.hpp>
@@ -10,19 +5,14 @@
namespace pdfengine {
// Human-readable engine version, e.g. "0.1.0".
[[nodiscard]] std::string_view engineVersion() noexcept;
// One-line build descriptor, e.g. "pdfengine 0.1.0 (pdfium=off)".
[[nodiscard]] std::string_view engineBuildInfo() noexcept;
// True if this build was compiled and linked against the PDFium parser core.
[[nodiscard]] bool engineHasPdfium() noexcept;
// True if this build was compiled and linked against the Skia graphics core.
[[nodiscard]] bool engineHasSkia() noexcept;
// Emits engineBuildInfo() through spdlog at info level.
void engineLogBuildInfo();
} // namespace pdfengine
}
+1 -5
View File
@@ -7,11 +7,8 @@ class SkCanvas;
namespace pdfengine {
// A visitor that replays a DisplayList onto a Skia canvas.
class SkiaRenderer : public CommandVisitor {
public:
// Takes a pointer to an external SkCanvas.
// The caller is responsible for the canvas's lifecycle.
explicit SkiaRenderer(SkCanvas* canvas);
void visit(const SaveStateCommand& cmd) override;
@@ -22,7 +19,6 @@ public:
void visit(const FillPathCommand& cmd) override;
void visit(const StrokePathCommand& cmd) override;
void visit(const DrawImageCommand& cmd) override;
// Renders the entire display list to the canvas
void render(const DisplayList& displayList);
private:
@@ -30,4 +26,4 @@ private:
GraphicsStateStack m_stateStack;
};
} // namespace pdfengine
}
+16 -17
View File
@@ -7,29 +7,28 @@
namespace pdfengine {
enum class TokenType {
Operator, // e.g., "Tj", "BT", "ET", "Tf", "re", "f"
String, // e.g., "(Hello World)", fully unescaped
HexString, // e.g., "<48656C6C6F>", fully decoded to bytes
Name, // e.g., "/F1" (without the slash, unescaped)
Number, // e.g., "12.3", "-4", stored as string/double
ArrayStart, // "["
ArrayEnd, // "]"
DictStart, // "<<"
DictEnd, // ">>"
Boolean, // "true", "false"
Null, // "null"
EndOfStream // EOF marker
Operator,
String,
HexString,
Name,
Number,
ArrayStart,
ArrayEnd,
DictStart,
DictEnd,
Boolean,
Null,
EndOfStream
};
struct Token {
TokenType type;
std::string stringValue; // Used for Operator, String, Name
std::vector<uint8_t> bytesValue; // Used for HexString
double numberValue = 0.0; // Used for Number
std::string stringValue;
std::vector<uint8_t> bytesValue;
double numberValue = 0.0;
// Position tracking for error reporting (optional but helpful)
size_t startOffset = 0;
size_t endOffset = 0;
};
} // namespace pdfengine
}
+1 -1
View File
@@ -55,4 +55,4 @@ void DisplayList::drawImage(const ImageInfo& image, const Matrix& m, float opaci
addCommand(std::make_unique<DrawImageCommand>(image, m, opacity));
}
} // namespace pdfengine
}
+1 -1
View File
@@ -37,4 +37,4 @@ void engineLogBuildInfo() {
spdlog::info("{}", engineBuildInfo());
}
} // namespace pdfengine
}
+1 -8
View File
@@ -21,12 +21,10 @@ void Matrix::transform(float& x, float& y) const noexcept {
}
GraphicsStateStack::GraphicsStateStack() {
// A PDF always starts with one default graphics state on the stack.
m_stack.emplace_back();
}
void GraphicsStateStack::push() {
// Duplicate the current state and push it onto the stack.
if (!m_stack.empty()) {
m_stack.push_back(m_stack.back());
} else {
@@ -35,14 +33,9 @@ void GraphicsStateStack::push() {
}
void GraphicsStateStack::pop() {
// The stack should never be empty, but we must protect against popping the initial state
// if the PDF is malformed (e.g. more 'Q' operators than 'q' operators).
if (m_stack.size() > 1) {
m_stack.pop_back();
} else {
// We could throw an exception or just ignore the invalid pop.
// For a resilient engine, ignoring is often better, but we could log a warning.
// For now, we do nothing to prevent crashing on the root state.
}
}
@@ -54,4 +47,4 @@ const GraphicsState& GraphicsStateStack::current() const {
return m_stack.back();
}
} // namespace pdfengine
}
+2 -4
View File
@@ -49,7 +49,7 @@ void jpegErrorExit(j_common_ptr cinfo) {
longjmp(manager->jump, 1);
}
} // namespace
}
namespace pdfengine {
@@ -291,8 +291,6 @@ std::vector<uint8_t> ImageDecoder::convertCmykToRgba(const std::vector<unsigned
float g = 255.0f;
float b = 255.0f;
// Match PDFium's default DeviceCMYK process primaries closely enough for
// unprofiled print images until ICCBased color management lands.
r = applyInk(r, 0.0f, c);
g = applyInk(g, 174.0f, c);
b = applyInk(b, 239.0f, c);
@@ -323,4 +321,4 @@ std::vector<uint8_t> ImageDecoder::convertCmykToRgba(const std::vector<unsigned
return rgba;
}
} // namespace pdfengine
}
+1 -6
View File
@@ -9,8 +9,6 @@ namespace pdfengine {
class ImageDecoder {
public:
// Decodes a PDF Image XObject into RGBA raw pixels.
// Supports FlateDecode (raw) and DCTDecode (JPEG).
static std::vector<uint8_t> decode(QPDFObjectHandle imageStream,
const std::string& colorSpace,
int width, int height,
@@ -21,14 +19,11 @@ private:
static std::vector<uint8_t> decodeJpeg(const std::vector<unsigned char>& jpegBytes);
static void applySoftMask(QPDFObjectHandle imageStream, std::vector<uint8_t>& rgba, int width, int height);
// Converts raw DeviceGray (1 byte per pixel) to RGBA (4 bytes per pixel)
static std::vector<uint8_t> convertGrayToRgba(const std::vector<unsigned char>& rawBytes, int width, int height);
// Converts raw DeviceRGB (3 bytes per pixel) to RGBA (4 bytes per pixel)
static std::vector<uint8_t> convertRgbToRgba(const std::vector<unsigned char>& rawBytes, int width, int height);
// Converts raw DeviceCMYK (4 bytes per pixel) to RGBA (4 bytes per pixel)
static std::vector<uint8_t> convertCmykToRgba(const std::vector<unsigned char>& rawBytes, int width, int height);
};
} // namespace pdfengine
}
+3 -13
View File
@@ -19,7 +19,6 @@ SkiaRenderer::SkiaRenderer(SkCanvas* canvas) : m_canvas(canvas) {}
void SkiaRenderer::render(const DisplayList& displayList) {
if (!m_canvas) return;
// Iterate and accept commands
displayList.replay(*this);
}
@@ -46,10 +45,6 @@ void SkiaRenderer::visit(const SetTransformCommand& cmd) {
#ifdef PDFENGINE_WITH_SKIA
if (m_canvas) {
SkMatrix skMatrix;
// Map 2D affine matrix to Skia's 3x3 matrix
// [ a c e ] (Skia) vs [ a b 0 ]
// [ b d f ] [ c d 0 ]
// [ 0 0 1 ] [ e f 1 ]
skMatrix.setAll(
cmd.matrix.a, cmd.matrix.c, cmd.matrix.e,
cmd.matrix.b, cmd.matrix.d, cmd.matrix.f,
@@ -58,7 +53,6 @@ void SkiaRenderer::visit(const SetTransformCommand& cmd) {
m_canvas->concat(skMatrix);
}
#endif
// Also update internal state if we need it later
m_stateStack.current().ctm = m_stateStack.current().ctm.multiply(cmd.matrix);
}
@@ -95,7 +89,6 @@ void SkiaRenderer::visit(const DrawTextCommand& cmd) {
static_cast<uint8_t>(color.g * 255),
static_cast<uint8_t>(color.b * 255)));
// For phase 0/1 testing, we use a default typeface
SkFont font(nullptr, 12.0f);
m_canvas->drawString(cmd.text.c_str(), cmd.x, cmd.y, font, paint);
@@ -174,13 +167,12 @@ void SkiaRenderer::visit(const StrokePathCommand& cmd) {
paint.setAntiAlias(true);
paint.setStyle(SkPaint::kStroke_Style);
// In a real implementation we would use strokeColor and strokeWidth from graphics state
const auto& color = m_stateStack.current().fillColor;
paint.setColor(SkColorSetARGB(255,
static_cast<uint8_t>(color.r * 255),
static_cast<uint8_t>(color.g * 255),
static_cast<uint8_t>(color.b * 255)));
paint.setStrokeWidth(1.0f); // Default for now
paint.setStrokeWidth(1.0f);
m_canvas->drawPath(skPath, paint);
#endif
@@ -191,7 +183,6 @@ void SkiaRenderer::visit(const DrawImageCommand& cmd) {
#ifdef PDFENGINE_WITH_SKIA
if (!m_canvas || cmd.image.pixelData.empty()) return;
// Create an SkImage from the raw RGBA pixels
SkImageInfo info = SkImageInfo::Make(
cmd.image.width,
cmd.image.height,
@@ -207,7 +198,7 @@ void SkiaRenderer::visit(const DrawImageCommand& cmd) {
sk_sp<SkImage> skImage = SkImages::RasterFromData(
info,
std::move(data),
cmd.image.width * 4 // rowBytes
cmd.image.width * 4
);
if (skImage) {
@@ -221,7 +212,6 @@ void SkiaRenderer::visit(const DrawImageCommand& cmd) {
);
m_canvas->concat(skMatrix);
// In PDF, images are drawn into a 1x1 rect at the origin in the current coordinate system
SkRect destRect = SkRect::MakeXYWH(0, 0, 1.0f, 1.0f);
SkPaint paint;
@@ -239,4 +229,4 @@ void SkiaRenderer::visit(const DrawImageCommand& cmd) {
#endif
}
} // namespace pdfengine
}
+7 -7
View File
@@ -5,12 +5,12 @@
namespace pdfengine::fonts {
struct GlyphBitmap {
std::vector<unsigned char> pixels; // 8-bit grayscale pixels (0 = transparent, 255 = fully opaque)
int width = 0; // Width of the glyph bitmap in pixels
int height = 0; // Height of the glyph bitmap in pixels
int bearingX = 0; // Horizontal bearing X (bitmap_left) in pixels
int bearingY = 0; // Horizontal bearing Y (bitmap_top) in pixels
double advance = 0.0; // Horizontal advance in pixels
std::vector<unsigned char> pixels;
int width = 0;
int height = 0;
int bearingX = 0;
int bearingY = 0;
double advance = 0.0;
};
} // namespace pdfengine::fonts
}
+2 -7
View File
@@ -26,11 +26,10 @@ std::optional<GlyphBitmap> GlyphCache::get(const FontFace& fontFace, unsigned in
auto it = shard.cache_map_.find(key);
if (it == shard.cache_map_.end()) {
shard.misses_++;
return std::nullopt; // Cache miss
return std::nullopt;
}
shard.hits_++;
// Cache hit: move the referenced key to the front of the LRU list
shard.lru_list_.splice(shard.lru_list_.begin(), shard.lru_list_, it->second.second);
return it->second.first;
@@ -49,23 +48,19 @@ void GlyphCache::insert(const FontFace& fontFace, unsigned int glyphIndex, unsig
std::lock_guard<std::mutex> lock(shard.mutex_);
auto it = shard.cache_map_.find(key);
if (it != shard.cache_map_.end()) {
// Element already exists: update bitmap and move it to the front
it->second.first = bitmap;
shard.lru_list_.splice(shard.lru_list_.begin(), shard.lru_list_, it->second.second);
return;
}
// Capacity per shard
std::size_t shard_capacity = (capacity_ + shards_.size() - 1) / shards_.size();
// Evict oldest element if at capacity
if (shard.cache_map_.size() >= shard_capacity && shard_capacity > 0) {
GlyphCacheKey oldest = shard.lru_list_.back();
shard.cache_map_.erase(oldest);
shard.lru_list_.pop_back();
}
// Insert new element
if (shard_capacity > 0) {
shard.lru_list_.push_front(key);
shard.cache_map_[key] = std::make_pair(bitmap, shard.lru_list_.begin());
@@ -120,4 +115,4 @@ void GlyphCache::resetStats() {
}
}
} // namespace pdfengine::fonts
}
+1 -11
View File
@@ -28,7 +28,6 @@ struct GlyphCacheKeyHash {
std::size_t h1 = std::hash<uint64_t>{}(key.fontId);
std::size_t h2 = std::hash<unsigned int>{}(key.glyphIndex);
std::size_t h3 = std::hash<unsigned int>{}(key.fontSize);
// Combine hashes using standard boost hash_combine algorithm
return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2)) ^ (h3 + 0x9e3779b9 + (h2 << 6) + (h2 >> 2));
}
};
@@ -38,31 +37,23 @@ public:
explicit GlyphCache(std::size_t capacity);
~GlyphCache();
// Cache is move-only to prevent copying internal list iterators
GlyphCache(const GlyphCache&) = delete;
GlyphCache& operator=(const GlyphCache&) = delete;
GlyphCache(GlyphCache&&) noexcept = default;
GlyphCache& operator=(GlyphCache&&) noexcept = default;
// Retrieves a glyph from the cache (and marks it as most recently used on hit)
std::optional<GlyphBitmap> get(const FontFace& fontFace, unsigned int glyphIndex, unsigned int fontSize);
// Inserts a glyph into the cache. Evicts the least recently used glyph if full.
void insert(const FontFace& fontFace, unsigned int glyphIndex, unsigned int fontSize, const GlyphBitmap& bitmap);
// Returns the current number of cached glyphs
std::size_t size() const;
// Returns the maximum capacity of the cache
std::size_t capacity() const;
// Clears all elements from the cache
void clear();
// Returns the cache hit rate (hits / (hits + misses)). Returns 0.0 if no lookups have occurred.
double hitRate() const;
// Resets hit and miss counters
void resetStats();
private:
@@ -85,10 +76,9 @@ private:
static constexpr std::size_t NUM_SHARDS = 16;
std::vector<std::unique_ptr<Shard>> shards_;
// Helper to get shard index based on key hash
std::size_t getShardIndex(const GlyphCacheKey& key) const {
return GlyphCacheKeyHash{}(key) % shards_.size();
}
};
} // namespace pdfengine::fonts
}
+2 -12
View File
@@ -65,8 +65,6 @@ bool FontFace::loadFromFile(const std::string& path) {
return false;
}
// Set a default pixel size of 16px so that font coordinates and shaping
// advances are non-zero by default.
FT_Set_Pixel_Sizes(face_, 0, 16);
return true;
@@ -83,7 +81,6 @@ bool FontFace::loadFromMemory(const std::vector<uint8_t>& data) {
face_ = nullptr;
}
// Copy to internal buffer to guarantee its lifetime aligns with face_
font_data_ = data;
if (FT_New_Memory_Face(
@@ -98,8 +95,6 @@ bool FontFace::loadFromMemory(const std::vector<uint8_t>& data) {
return false;
}
// Set a default pixel size of 16px so that font coordinates and shaping
// advances are non-zero by default.
FT_Set_Pixel_Sizes(face_, 0, 16);
return true;
@@ -113,10 +108,9 @@ bool FontFace::coversUnicode(uint32_t codepoint) const {
if (!face_) return false;
std::lock_guard<std::mutex> lock(*mutex_);
FT_CharMap prev = face_->charmap;
// Ignore failure: if there's no Unicode cmap, keep whatever charmap is current.
FT_Select_Charmap(face_, FT_ENCODING_UNICODE);
FT_UInt gid = FT_Get_Char_Index(face_, codepoint);
if (prev) FT_Set_Charmap(face_, prev); // restore so shaping/measurement is unaffected
if (prev) FT_Set_Charmap(face_, prev);
return gid != 0;
}
@@ -135,12 +129,10 @@ std::optional<GlyphBitmap> FontFace::renderGlyph(unsigned int glyphIndex, unsign
std::lock_guard<std::mutex> lock(*mutex_);
// Set font size in pixels.
if (FT_Set_Pixel_Sizes(face_, 0, fontSize)) {
return std::nullopt;
}
// Load and render the glyph bitmap into the face->glyph slot.
if (FT_Load_Glyph(face_, glyphIndex, FT_LOAD_RENDER)) {
return std::nullopt;
}
@@ -154,10 +146,8 @@ std::optional<GlyphBitmap> FontFace::renderGlyph(unsigned int glyphIndex, unsign
glyph_bitmap.bearingX = slot->bitmap_left;
glyph_bitmap.bearingY = slot->bitmap_top;
// Advance is in 26.6 fractional pixels. Convert to double.
glyph_bitmap.advance = static_cast<double>(slot->advance.x) / 64.0;
// Extract the pixels. Pitch specifies bytes per row.
if (glyph_bitmap.width > 0 && glyph_bitmap.height > 0) {
glyph_bitmap.pixels.resize(glyph_bitmap.width * glyph_bitmap.height);
for (int r = 0; r < glyph_bitmap.height; ++r) {
@@ -172,4 +162,4 @@ std::optional<GlyphBitmap> FontFace::renderGlyph(unsigned int glyphIndex, unsign
return glyph_bitmap;
}
} // namespace pdfengine::fonts
}
+2 -8
View File
@@ -18,7 +18,6 @@ public:
FontFace();
~FontFace();
// FontFace is move-only to avoid double freeing FreeType resources.
FontFace(const FontFace&) = delete;
FontFace& operator=(const FontFace&) = delete;
FontFace(FontFace&& other) noexcept;
@@ -31,20 +30,15 @@ public:
uint64_t getId() const;
std::mutex& getMutex() const;
// True if this font can map `codepoint` to a real glyph via its Unicode cmap. Selects the
// Unicode charmap first (so codepoints resolve through the (3,1)/(0,x) subtable rather than a
// symbol cmap that would false-negate), then restores the prior charmap. Used to decide
// whether to keep smart punctuation (en-dash, curly quotes) or ASCII-degrade it on reflow.
bool coversUnicode(uint32_t codepoint) const;
// Renders a glyph by index and size, returning a GlyphBitmap on success.
std::optional<GlyphBitmap> renderGlyph(unsigned int glyphIndex, unsigned int fontSize);
private:
uint64_t font_id_;
FT_Face face_;
std::unique_ptr<std::mutex> mutex_;
std::vector<uint8_t> font_data_; // Keeps the loaded memory buffer alive for FT_Face
std::vector<uint8_t> font_data_;
};
} // namespace pdfengine::fonts
}
+1 -1
View File
@@ -25,4 +25,4 @@ FT_Library FreeTypeManager::getLibrary() const {
return library_;
}
} // namespace pdfengine::fonts
}
+1 -2
View File
@@ -11,7 +11,6 @@ public:
FT_Library getLibrary() const;
// Delete copy/move constructors and assignment operators for singleton
FreeTypeManager(const FreeTypeManager&) = delete;
FreeTypeManager& operator=(const FreeTypeManager&) = delete;
FreeTypeManager(FreeTypeManager&&) = delete;
@@ -24,4 +23,4 @@ private:
FT_Library library_;
};
} // namespace pdfengine::fonts
}
+1 -5
View File
@@ -21,7 +21,6 @@ std::expected<std::unique_ptr<pdf_fonts::Font>, std::string> FontResolver::resol
const auto& bytes = dataRes.value();
// Build FontDescriptor from FontInfo
auto descriptor = std::make_unique<pdf_fonts::FontDescriptor>();
descriptor->setFontName(fontInfo.fontName);
descriptor->setFlags(fontInfo.flags);
@@ -29,7 +28,6 @@ std::expected<std::unique_ptr<pdf_fonts::Font>, std::string> FontResolver::resol
descriptor->setDescent(fontInfo.descent);
descriptor->setCapHeight(fontInfo.capHeight);
// Route to the appropriate FontLoader method based on FontInfo type
if (fontInfo.type == "TrueType") {
auto font = pdf_fonts::FontLoader::loadTrueTypeFromMemory(fontInfo.normalizedFamily, bytes, std::move(descriptor));
if (font) return font;
@@ -47,10 +45,8 @@ std::expected<std::unique_ptr<pdf_fonts::Font>, std::string> FontResolver::resol
return std::unexpected("Failed to parse extracted font data");
} else {
// Handle System Fallback
spdlog::info("Resolving system fallback font for: {}", fontInfo.fontName);
// Build FontDescriptor from FontInfo
auto descriptor = std::make_unique<pdf_fonts::FontDescriptor>();
descriptor->setFontName(fontInfo.fontName);
descriptor->setFlags(fontInfo.flags);
@@ -73,4 +69,4 @@ std::expected<std::unique_ptr<pdf_fonts::Font>, std::string> FontResolver::resol
}
}
} // namespace pdfengine::fonts::loader
}
+1 -4
View File
@@ -12,13 +12,10 @@ class FontResolver {
public:
explicit FontResolver(std::shared_ptr<PdfDocument> document);
// Resolves a FontInfo object into a fully loaded Font ready for shaping.
// If the font is embedded, it extracts the raw bytes from the PdfDocument.
// If it's a system fallback, it uses FontLoader to load it from the OS.
std::expected<std::unique_ptr<pdf_fonts::Font>, std::string> resolveFont(const FontInfo& fontInfo);
private:
std::shared_ptr<PdfDocument> document_;
};
} // namespace pdfengine::fonts::loader
}
@@ -5,230 +5,218 @@ namespace pdfengine::fonts::pdf_fonts {
uint32_t CjkCollectionDB::resolveCID(const std::string& collection, uint32_t cid) {
if (collection == "Adobe-Japan1" || collection.find("Japan") != std::string::npos) {
// Hiragana mapping (basic Hiragana starts from U+3041 to U+3093)
// CIDs 1010 to 1092 inside standard Adobe-Japan1 map to Hiragana
if (cid >= 1010 && cid <= 1092) {
return 0x3041 + (cid - 1010);
}
// Katakana mapping (basic Katakana starts from U+30A1 to U+30F6)
// CIDs 1125 to 1205 inside standard Adobe-Japan1 map to Katakana
if (cid >= 1125 && cid <= 1205) {
if (cid == 1205) return 0x30F6;
return 0x30A1 + (cid - 1125);
}
// Expanded Kanji mappings (CIDs 1206-1255)
// Maps common Japanese Kanji in the JIS X 0208 standard block to Unicode
if (cid >= 1206 && cid <= 1255) {
switch (cid) {
case 1206: return 0x4E00; // 一
case 1207: return 0x4E01; // 丁
case 1208: return 0x4E03; // 七
case 1209: return 0x4E07; // 万
case 1210: return 0x4E08; // 丈
case 1211: return 0x4E09; // 三
case 1212: return 0x4E0A; // 上
case 1213: return 0x4E0B; // 下
case 1214: return 0x4E0D; // 不
case 1215: return 0x4E0E; // 与
case 1216: return 0x4E10; // 丐
case 1217: return 0x4E11; // 丑
case 1218: return 0x4E14; // 且
case 1219: return 0x4E15; // 丕
case 1220: return 0x4E16; // 世
case 1221: return 0x4E17; // 丘
case 1222: return 0x4E18; // 丙
case 1223: return 0x4E19; // 両
case 1224: return 0x4E1D; // 丞
case 1225: return 0x4E2D; // 中
case 1226: return 0x4E32; // 串
case 1227: return 0x4E38; // 丸
case 1228: return 0x4E39; // 丹
case 1229: return 0x4E3B; // 主
case 1230: return 0x4E3C; // 丼
case 1231: return 0x4E3F; // 丿
case 1232: return 0x4E42; // 乂
case 1233: return 0x4E43; // 乃
case 1234: return 0x4E45; // 久
case 1235: return 0x4E4B; // 之
case 1236: return 0x4E4D; // 乍
case 1237: return 0x4E4E; // 乎
case 1238: return 0x4E4F; // 乏
case 1239: return 0x4E56; // 乖
case 1240: return 0x4E57; // 乗
case 1241: return 0x4E58; // 乘
case 1242: return 0x4E59; // 乙
case 1243: return 0x4E5D; // 九
case 1244: return 0x4E5E; // 乞
case 1245: return 0x4E5F; // 也
case 1246: return 0x4E62; // 乱
case 1247: return 0x4E73; // 乳
case 1248: return 0x4E7E; // 乾
case 1249: return 0x4E82; // 亂
case 1250: return 0x4E86; // 了
case 1251: return 0x4E88; // 予
case 1252: return 0x4E89; // 争
case 1253: return 0x4E8B; // 事
case 1254: return 0x4E8C; // 二
case 1255: return 0x4E8E; // 于
case 1206: return 0x4E00;
case 1207: return 0x4E01;
case 1208: return 0x4E03;
case 1209: return 0x4E07;
case 1210: return 0x4E08;
case 1211: return 0x4E09;
case 1212: return 0x4E0A;
case 1213: return 0x4E0B;
case 1214: return 0x4E0D;
case 1215: return 0x4E0E;
case 1216: return 0x4E10;
case 1217: return 0x4E11;
case 1218: return 0x4E14;
case 1219: return 0x4E15;
case 1220: return 0x4E16;
case 1221: return 0x4E17;
case 1222: return 0x4E18;
case 1223: return 0x4E19;
case 1224: return 0x4E1D;
case 1225: return 0x4E2D;
case 1226: return 0x4E32;
case 1227: return 0x4E38;
case 1228: return 0x4E39;
case 1229: return 0x4E3B;
case 1230: return 0x4E3C;
case 1231: return 0x4E3F;
case 1232: return 0x4E42;
case 1233: return 0x4E43;
case 1234: return 0x4E45;
case 1235: return 0x4E4B;
case 1236: return 0x4E4D;
case 1237: return 0x4E4E;
case 1238: return 0x4E4F;
case 1239: return 0x4E56;
case 1240: return 0x4E57;
case 1241: return 0x4E58;
case 1242: return 0x4E59;
case 1243: return 0x4E5D;
case 1244: return 0x4E5E;
case 1245: return 0x4E5F;
case 1246: return 0x4E62;
case 1247: return 0x4E73;
case 1248: return 0x4E7E;
case 1249: return 0x4E82;
case 1250: return 0x4E86;
case 1251: return 0x4E88;
case 1252: return 0x4E89;
case 1253: return 0x4E8B;
case 1254: return 0x4E8C;
case 1255: return 0x4E8E;
default: break;
}
}
}
else if (collection == "Adobe-Korea1" || collection.find("Korea") != std::string::npos) {
// Standard Korean Hangul Syllables mapping (U+AC00 block)
// CIDs 101 to 150 map to the first segment of KS X 1001 Hangul syllables
if (cid >= 101 && cid <= 160) {
switch (cid) {
case 101: return 0xAC00; // 가
case 102: return 0xAC01; // 각
case 103: return 0xAC04; // 간
case 104: return 0xAC07; // 갇
case 105: return 0xAC08; // 갈
case 106: return 0xAC09; // 갉
case 107: return 0xAC0A; // 갊
case 108: return 0xAC10; // 감
case 109: return 0xAC11; // 갑
case 110: return 0xAC12; // 값
case 111: return 0xAC13; // 갓
case 112: return 0xAC14; // 갔
case 113: return 0xAC15; // 강
case 114: return 0xAC16; // 갖
case 115: return 0xAC17; // 갗
case 116: return 0xAC19; // 같
case 117: return 0xAC1A; // 갚
case 118: return 0xAC1B; // 갛
case 119: return 0xAC1C; // 개
case 120: return 0xAC1D; // 객
case 121: return 0xAC20; // 갠
case 122: return 0xAC24; // 갤
case 123: return 0xAC2C; // 갬
case 124: return 0xAC2D; // 갭
case 125: return 0xAC2F; // 갯
case 126: return 0xAC30; // 갰
case 127: return 0xAC31; // 갱
case 128: return 0xAC38; // 갸
case 129: return 0xAC39; // 갹
case 130: return 0xAC3C; // 갼
case 131: return 0xAC40; // 걀
case 132: return 0xAC48; // 걈
case 133: return 0xAC49; // 걉
case 134: return 0xAC4B; // 걋
case 135: return 0xAC4C; // 걍
case 136: return 0xAC54; // 개의
case 137: return 0xAC70; // 거
case 138: return 0xAC71; // 걱
case 139: return 0xAC74; // 건
case 140: return 0xAC77; // 걷
case 141: return 0xAC78; // 걸
case 142: return 0xAC7A; // 걺
case 143: return 0xAC80; // 검
case 144: return 0xAC81; // 겁
case 145: return 0xAC83; // 것
case 146: return 0xAC84; // 겄
case 147: return 0xAC85; // 겡
case 148: return 0xAC8C; // 게
case 149: return 0xAC8D; // 겐
case 150: return 0xAC90; // 겔
case 151: return 0xAC94; // 겝
case 152: return 0xAC9F; // 겟
case 153: return 0xACA0; // 겠
case 154: return 0xACA1; // 겡
case 155: return 0xACA8; // 겯
case 156: return 0xACA9; // 결
case 157: return 0xACB8; // 겸
case 158: return 0xACB9; // 겹
case 159: return 0xACBC; // 겻
case 160: return 0xACBD; // 겼
case 101: return 0xAC00;
case 102: return 0xAC01;
case 103: return 0xAC04;
case 104: return 0xAC07;
case 105: return 0xAC08;
case 106: return 0xAC09;
case 107: return 0xAC0A;
case 108: return 0xAC10;
case 109: return 0xAC11;
case 110: return 0xAC12;
case 111: return 0xAC13;
case 112: return 0xAC14;
case 113: return 0xAC15;
case 114: return 0xAC16;
case 115: return 0xAC17;
case 116: return 0xAC19;
case 117: return 0xAC1A;
case 118: return 0xAC1B;
case 119: return 0xAC1C;
case 120: return 0xAC1D;
case 121: return 0xAC20;
case 122: return 0xAC24;
case 123: return 0xAC2C;
case 124: return 0xAC2D;
case 125: return 0xAC2F;
case 126: return 0xAC30;
case 127: return 0xAC31;
case 128: return 0xAC38;
case 129: return 0xAC39;
case 130: return 0xAC3C;
case 131: return 0xAC40;
case 132: return 0xAC48;
case 133: return 0xAC49;
case 134: return 0xAC4B;
case 135: return 0xAC4C;
case 136: return 0xAC54;
case 137: return 0xAC70;
case 138: return 0xAC71;
case 139: return 0xAC74;
case 140: return 0xAC77;
case 141: return 0xAC78;
case 142: return 0xAC7A;
case 143: return 0xAC80;
case 144: return 0xAC81;
case 145: return 0xAC83;
case 146: return 0xAC84;
case 147: return 0xAC85;
case 148: return 0xAC8C;
case 149: return 0xAC8D;
case 150: return 0xAC90;
case 151: return 0xAC94;
case 152: return 0xAC9F;
case 153: return 0xACA0;
case 154: return 0xACA1;
case 155: return 0xACA8;
case 156: return 0xACA9;
case 157: return 0xACB8;
case 158: return 0xACB9;
case 159: return 0xACBC;
case 160: return 0xACBD;
default: break;
}
}
}
else if (collection == "Adobe-CNS1" || collection.find("CNS1") != std::string::npos) {
// Standard Traditional Chinese mappings (U+4E00 block)
// CIDs 100 onwards maps core Traditional Chinese characters
if (cid >= 100 && cid <= 140) {
switch (cid) {
case 100: return 0x4E00; // 一
case 101: return 0x4E03; // 七
case 102: return 0x4E07; // 万
case 103: return 0x4E09; // 三
case 104: return 0x4E0A; // 上
case 105: return 0x4E0B; // 下
case 106: return 0x4E10; // 丐
case 107: return 0x4E11; // 丑
case 108: return 0x4E14; // 且
case 109: return 0x4E15; // 丕
case 110: return 0x4E16; // 世
case 111: return 0x4E18; // 丙
case 112: return 0x4E2D; // 中
case 113: return 0x4E86; // 了
case 114: return 0x4E92; // 互
case 115: return 0x4E95; // 井
case 116: return 0x4E99; // 亥
case 117: return 0x4EBA; // 人
case 118: return 0x4EC0; // 什
case 119: return 0x4EC1; // 仁
case 120: return 0x4EC4; // 仃
case 121: return 0x4EC6; // 仄
case 122: return 0x4EC7; // 仇
case 123: return 0x4ECA; // 今
case 124: return 0x4ECB; // 介
case 125: return 0x4ECD; // 仍
case 126: return 0x4ECE; // 从
case 127: return 0x4ED4; // 仔
case 128: return 0x4ED5; // 仕
case 129: return 0x4ED6; // 他
case 130: return 0x4ED7; // 仗
case 131: return 0x4ED8; // 付
case 132: return 0x4ED9; // 仙
case 133: return 0x4EDD; // 仝
case 134: return 0x4EDE; // 仞
case 135: return 0x4EDF; // 仟
case 136: return 0x4EE1; // 仡
case 137: return 0x4EE3; // 代
case 138: return 0x4EE4; // 令
case 139: return 0x4EE5; // 以
case 140: return 0x4F01; // 企
case 100: return 0x4E00;
case 101: return 0x4E03;
case 102: return 0x4E07;
case 103: return 0x4E09;
case 104: return 0x4E0A;
case 105: return 0x4E0B;
case 106: return 0x4E10;
case 107: return 0x4E11;
case 108: return 0x4E14;
case 109: return 0x4E15;
case 110: return 0x4E16;
case 111: return 0x4E18;
case 112: return 0x4E2D;
case 113: return 0x4E86;
case 114: return 0x4E92;
case 115: return 0x4E95;
case 116: return 0x4E99;
case 117: return 0x4EBA;
case 118: return 0x4EC0;
case 119: return 0x4EC1;
case 120: return 0x4EC4;
case 121: return 0x4EC6;
case 122: return 0x4EC7;
case 123: return 0x4ECA;
case 124: return 0x4ECB;
case 125: return 0x4ECD;
case 126: return 0x4ECE;
case 127: return 0x4ED4;
case 128: return 0x4ED5;
case 129: return 0x4ED6;
case 130: return 0x4ED7;
case 131: return 0x4ED8;
case 132: return 0x4ED9;
case 133: return 0x4EDD;
case 134: return 0x4EDE;
case 135: return 0x4EDF;
case 136: return 0x4EE1;
case 137: return 0x4EE3;
case 138: return 0x4EE4;
case 139: return 0x4EE5;
case 140: return 0x4F01;
default: break;
}
}
}
else if (collection == "Adobe-GB1" || collection.find("GB1") != std::string::npos) {
// Standard GB simplified Chinese maps
if (cid == 1) return 0x3000; // Ideographic space
if (cid == 2) return 0x3001; // Ideographic comma
if (cid == 3) return 0x3002; // Ideographic full stop
if (cid == 1) return 0x3000;
if (cid == 2) return 0x3001;
if (cid == 3) return 0x3002;
// Additional common GB1 characters
if (cid >= 100 && cid <= 120) {
switch (cid) {
case 100: return 0x4E00; // 一
case 101: return 0x4E03; // 七
case 102: return 0x4E07; // 万
case 103: return 0x4E09; // 三
case 104: return 0x4E0A; // 上
case 105: return 0x4E0B; // 下
case 106: return 0x4E10; // 丐
case 107: return 0x4E11; // 丑
case 108: return 0x4E14; // 且
case 109: return 0x4E15; // 丕
case 110: return 0x4E16; // 世
case 111: return 0x4E18; // 丙
case 112: return 0x4E2D; // 中
case 113: return 0x4E86; // 了
case 114: return 0x4E92; // 互
case 115: return 0x4E95; // 井
case 116: return 0x4E99; // 亥
case 117: return 0x4EBA; // 人
case 118: return 0x4EC0; // 什
case 119: return 0x4EC1; // 仁
case 120: return 0x4EC4; // 仃
case 100: return 0x4E00;
case 101: return 0x4E03;
case 102: return 0x4E07;
case 103: return 0x4E09;
case 104: return 0x4E0A;
case 105: return 0x4E0B;
case 106: return 0x4E10;
case 107: return 0x4E11;
case 108: return 0x4E14;
case 109: return 0x4E15;
case 110: return 0x4E16;
case 111: return 0x4E18;
case 112: return 0x4E2D;
case 113: return 0x4E86;
case 114: return 0x4E92;
case 115: return 0x4E95;
case 116: return 0x4E99;
case 117: return 0x4EBA;
case 118: return 0x4EC0;
case 119: return 0x4EC1;
case 120: return 0x4EC4;
default: break;
}
}
}
return 0; // fallback
return 0;
}
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -7,9 +7,7 @@ namespace pdfengine::fonts::pdf_fonts {
class CjkCollectionDB {
public:
// Resolves a CID inside a standard collection (e.g. "Adobe-Japan1") to a Unicode codepoint.
// Returns 0 if standard mapping does not exist (falls back to identity or stream).
static uint32_t resolveCID(const std::string& collection, uint32_t cid);
};
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -10,61 +10,58 @@ namespace pdfengine::fonts::pdf_fonts {
namespace {
// Maps Windows CP-1252 exceptions (0x80 - 0x9F) for WinAnsiEncoding
uint32_t getWinAnsiException(uint32_t code) {
switch (code) {
case 128: return 0x20AC; // Euro
case 130: return 0x201A; // Single low-9 quote
case 131: return 0x0192; // Florin
case 132: return 0x201E; // Double low-9 quote
case 133: return 0x2026; // Ellipsis
case 134: return 0x2020; // Dagger
case 135: return 0x2021; // Double Dagger
case 136: return 0x02C6; // Circumflex
case 137: return 0x2030; // Per mille
case 138: return 0x0160; // S Caron
case 139: return 0x2039; // Single guillemet left
case 140: return 0x0152; // OE
case 142: return 0x017D; // Z Caron
case 145: return 0x2018; // Single quote left
case 146: return 0x2019; // Single quote right
case 147: return 0x201C; // Double quote left
case 148: return 0x201D; // Double quote right
case 149: return 0x2022; // Bullet
case 150: return 0x2013; // En dash
case 151: return 0x2014; // Em dash
case 152: return 0x02DC; // Tilde
case 153: return 0x2122; // Trademark
case 154: return 0x0161; // s Caron
case 155: return 0x203A; // Single guillemet right
case 156: return 0x0153; // oe
case 158: return 0x017E; // z Caron
case 159: return 0x0178; // Y Dieresis
case 128: return 0x20AC;
case 130: return 0x201A;
case 131: return 0x0192;
case 132: return 0x201E;
case 133: return 0x2026;
case 134: return 0x2020;
case 135: return 0x2021;
case 136: return 0x02C6;
case 137: return 0x2030;
case 138: return 0x0160;
case 139: return 0x2039;
case 140: return 0x0152;
case 142: return 0x017D;
case 145: return 0x2018;
case 146: return 0x2019;
case 147: return 0x201C;
case 148: return 0x201D;
case 149: return 0x2022;
case 150: return 0x2013;
case 151: return 0x2014;
case 152: return 0x02DC;
case 153: return 0x2122;
case 154: return 0x0161;
case 155: return 0x203A;
case 156: return 0x0153;
case 158: return 0x017E;
case 159: return 0x0178;
default: return 0;
}
}
// Maps Standard MacRoman characters from 128 to 255
const uint32_t kMacRomanHighPage[128] = {
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, // 128-135
0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, // 136-143
0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, // 144-151
0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, // 152-159
0x2020, 0x00B0, 0x00A2, 0x00A3, 0x00A7, 0x2022, 0x00B6, 0x00DF, // 160-167
0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, // 168-175
0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211, // 176-183
0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x2126, 0x00E6, 0x00F8, // 184-191
0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB, // 192-199
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, // 200-207
0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, // 208-215
0x00FF, 0x0178, 0x2044, 0x00A4, 0x2039, 0x203A, 0xFB01, 0xFB02, // 216-223
0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, // 224-231
0x00CB, 0x00C8, 0x00CD, 0x00CE, 0x00CF, 0x00CC, 0x00D3, 0x00D4, // 232-239
0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, // 240-247
0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7 // 248-255
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1,
0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8,
0x00EA, 0x00EB, 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3,
0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC,
0x2020, 0x00B0, 0x00A2, 0x00A3, 0x00A7, 0x2022, 0x00B6, 0x00DF,
0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8,
0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, 0x2202, 0x2211,
0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x2126, 0x00E6, 0x00F8,
0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153,
0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA,
0x00FF, 0x0178, 0x2044, 0x00A4, 0x2039, 0x203A, 0xFB01, 0xFB02,
0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1,
0x00CB, 0x00C8, 0x00CD, 0x00CE, 0x00CF, 0x00CC, 0x00D3, 0x00D4,
0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC,
0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, 0x02DB, 0x02C7
};
// Safe hexadecimal digit conversions
bool parseHexValue(const std::string& hexStr, uint32_t& value) {
if (hexStr.empty()) return false;
std::string cleanHex;
@@ -83,11 +80,8 @@ bool parseHexValue(const std::string& hexStr, uint32_t& value) {
}
}
} // namespace
}
// ==========================================
// PredefinedEncoding Implementation
// ==========================================
PredefinedEncoding::PredefinedEncoding(SimpleEncodingType type) : type_(type) {}
uint32_t PredefinedEncoding::decode(uint32_t charCode) const {
@@ -115,9 +109,6 @@ uint32_t PredefinedEncoding::decode(uint32_t charCode) const {
return charCode;
}
// ==========================================
// CustomEncoding Implementation
// ==========================================
CustomEncoding::CustomEncoding(std::unique_ptr<Encoding> baseEncoding)
: base_(std::move(baseEncoding)) {}
@@ -136,9 +127,6 @@ void CustomEncoding::addDifference(uint32_t code, const std::string& glyphName)
}
}
// ==========================================
// ToUnicodeCMap Implementation
// ==========================================
uint32_t ToUnicodeCMap::decode(uint32_t charCode) const {
auto it = cmap_.find(charCode);
if (it != cmap_.end()) {
@@ -151,9 +139,6 @@ bool ToUnicodeCMap::parseCMapStream(const std::string& streamStr) {
return ToUnicodeParser::parse(streamStr, cmap_);
}
// ==========================================
// Adobe Glyph List (AGL) Implementation
// ==========================================
uint32_t resolveGlyphNameToUnicode(const std::string& name) {
if (name.empty()) return 0;
@@ -209,4 +194,4 @@ uint32_t resolveGlyphNameToUnicode(const std::string& name) {
return 0;
}
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -17,16 +17,13 @@ enum class SimpleEncodingType {
Identity_V
};
// Base interface for PDF character code to Unicode codepoint translation
class Encoding {
public:
virtual ~Encoding() = default;
// Translates a PDF character code (typically 1-4 bytes) to a Unicode codepoint
virtual uint32_t decode(uint32_t charCode) const = 0;
};
// Standard predefined simple encodings
class PredefinedEncoding : public Encoding {
public:
explicit PredefinedEncoding(SimpleEncodingType type);
@@ -39,7 +36,6 @@ private:
SimpleEncodingType type_;
};
// Custom encodings constructed with a base predefined encoding and a set of `/Differences`
class CustomEncoding : public Encoding {
public:
explicit CustomEncoding(std::unique_ptr<Encoding> baseEncoding);
@@ -47,7 +43,6 @@ public:
uint32_t decode(uint32_t charCode) const override;
// Maps a character code to a postscript glyph name using Adobe Glyph List (AGL)
void addDifference(uint32_t code, const std::string& glyphName);
private:
@@ -55,7 +50,6 @@ private:
std::unordered_map<uint32_t, uint32_t> custom_map_;
};
// Advanced CMaps for CJK/CID /ToUnicode translation streams
class ToUnicodeCMap : public Encoding {
public:
ToUnicodeCMap() = default;
@@ -63,10 +57,8 @@ public:
uint32_t decode(uint32_t charCode) const override;
// Parses a `/ToUnicode` CMap definition from a PDF stream string
bool parseCMapStream(const std::string& streamStr);
// Explicitly add mapping for testing
void addMapping(uint32_t code, uint32_t unicode) {
cmap_[code] = unicode;
}
@@ -75,7 +67,6 @@ private:
std::unordered_map<uint32_t, uint32_t> cmap_;
};
// Adobe Glyph List resolver: translates a standard PostScript glyph name to a Unicode codepoint.
uint32_t resolveGlyphNameToUnicode(const std::string& name);
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -9,7 +9,6 @@ namespace pdfengine::fonts::pdf_fonts {
namespace {
// Helper to convert hex strings (e.g. "<001E>") to uint32_t
bool parseHexValue(const std::string& hexStr, uint32_t& value) {
if (hexStr.empty()) return false;
std::string cleanHex;
@@ -28,20 +27,17 @@ bool parseHexValue(const std::string& hexStr, uint32_t& value) {
}
}
// Tokenizes CMap stream input, ignoring comments and robustly handling delimiters to prevent hangs
std::vector<std::string> tokenizeCMap(const std::string& input) {
std::vector<std::string> tokens;
size_t i = 0;
size_t len = input.length();
while (i < len) {
// Skip whitespaces
if (std::isspace(static_cast<unsigned char>(input[i])) || input[i] == '\0') {
i++;
continue;
}
// Bypassing comments starting with %
if (input[i] == '%') {
while (i < len && input[i] != '\n' && input[i] != '\r') {
i++;
@@ -49,7 +45,6 @@ std::vector<std::string> tokenizeCMap(const std::string& input) {
continue;
}
// Check for double angle brackets << and >> (delimiters)
if (i + 1 < len && input[i] == '<' && input[i + 1] == '<') {
tokens.push_back("<<");
i += 2;
@@ -61,7 +56,6 @@ std::vector<std::string> tokenizeCMap(const std::string& input) {
continue;
}
// Parse hex strings <001E>
if (input[i] == '<') {
std::string hexToken;
hexToken += input[i++];
@@ -75,7 +69,6 @@ std::vector<std::string> tokenizeCMap(const std::string& input) {
continue;
}
// Check for other standalone delimiters
if (input[i] == '[' || input[i] == ']' || input[i] == '>') {
std::string delimToken(1, input[i]);
tokens.push_back(delimToken);
@@ -83,7 +76,6 @@ std::vector<std::string> tokenizeCMap(const std::string& input) {
continue;
}
// Standard token parsing
std::string token;
while (i < len && !std::isspace(static_cast<unsigned char>(input[i])) &&
input[i] != '\0' && input[i] != '%' && input[i] != '<' && input[i] != '>' &&
@@ -97,7 +89,7 @@ std::vector<std::string> tokenizeCMap(const std::string& input) {
return tokens;
}
} // namespace
}
bool ToUnicodeParser::parse(const std::string& streamStr, std::unordered_map<uint32_t, uint32_t>& outMap) {
std::vector<std::string> tokens = tokenizeCMap(streamStr);
@@ -141,8 +133,7 @@ bool ToUnicodeParser::parse(const std::string& streamStr, std::unordered_map<uin
if (parseHexValue(startToken, startCode) && parseHexValue(endToken, endCode)) {
if (destToken == "[") {
// Array mapping: e.g. <0001> <0003> [<0041> <0042> <0043>]
idx += 3; // skip start, end, "["
idx += 3;
uint32_t currentCode = startCode;
while (idx < size && tokens[idx] != "]" && currentCode <= endCode) {
uint32_t destVal = 0;
@@ -159,7 +150,6 @@ bool ToUnicodeParser::parse(const std::string& streamStr, std::unordered_map<uin
continue;
}
else {
// Sequential base mapping: e.g. <0001> <0003> <0041>
uint32_t destStart = 0;
if (parseHexValue(destToken, destStart)) {
for (uint32_t code = startCode; code <= endCode; ++code) {
@@ -182,4 +172,4 @@ bool ToUnicodeParser::parse(const std::string& streamStr, std::unordered_map<uin
return parsedAny;
}
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -8,9 +8,7 @@ namespace pdfengine::fonts::pdf_fonts {
class ToUnicodeParser {
public:
// Parses a ToUnicode CMap stream and populates the provided mapping
// Returns true if at least one valid mapping was parsed, false otherwise
static bool parse(const std::string& streamStr, std::unordered_map<uint32_t, uint32_t>& outMap);
};
} // namespace pdfengine::fonts::pdf_fonts
}
+2 -14
View File
@@ -40,19 +40,15 @@ public:
virtual FontType getType() const = 0;
virtual bool isEmbedded() const = 0;
// Gets reference to underlying FontFace rendering object
virtual pdfengine::fonts::FontFace& getFontFace() = 0;
virtual const pdfengine::fonts::FontFace& getFontFace() const = 0;
// Gets the font descriptor (returns nullptr if none exists)
virtual const FontDescriptor* getDescriptor() const = 0;
// Gets the font encoding (returns nullptr if none exists)
virtual const Encoding* getEncoding() const = 0;
virtual const FontSubset* getSubsetInfo() const = 0;
// Advanced Layout APIs
virtual FontSource getSourceType() const { return source_type_; }
virtual void setSourceType(FontSource source) { source_type_ = source; }
@@ -87,17 +83,15 @@ public:
FT_Face rawFace = getFontFace().getFace();
FT_Set_Pixel_Sizes(rawFace, 0, static_cast<FT_UInt>(fontSize));
// Convert from 26.6 to double
metrics_cache_.ascent = static_cast<double>(rawFace->size->metrics.ascender) / 64.0;
metrics_cache_.descent = static_cast<double>(rawFace->size->metrics.descender) / 64.0;
metrics_cache_.lineGap = static_cast<double>(rawFace->size->metrics.height - rawFace->size->metrics.ascender + rawFace->size->metrics.descender) / 64.0;
// Heuristic for capHeight: height of 'H'
FT_UInt hIndex = FT_Get_Char_Index(rawFace, 'H');
if (hIndex > 0 && FT_Load_Glyph(rawFace, hIndex, FT_LOAD_DEFAULT) == 0) {
metrics_cache_.capHeight = static_cast<double>(rawFace->glyph->metrics.horiBearingY) / 64.0;
} else {
metrics_cache_.capHeight = metrics_cache_.ascent * 0.7; // Fallback
metrics_cache_.capHeight = metrics_cache_.ascent * 0.7;
}
metrics_cached_ = true;
@@ -106,13 +100,10 @@ public:
return metrics_cache_;
}
// Translates a raw character code to a Unicode codepoint
virtual uint32_t decodeToUnicode(uint32_t charCode) const = 0;
// Translates a sequence of raw character codes to a UTF-8 string
virtual std::string decodeStringToUnicode(const std::vector<uint32_t>& charCodes) const = 0;
// Widths methods
virtual void setWidths(uint32_t firstChar, uint32_t lastChar, const std::vector<double>& widths) {
first_char_ = firstChar;
last_char_ = lastChar;
@@ -131,12 +122,9 @@ public:
if (charCode >= first_char_ && charCode <= last_char_) {
size_t index = charCode - first_char_;
if (index < widths_.size()) {
// PDF widths are in 1/1000 units of text space.
// Scale to requested fontSize.
return (widths_[index] / 1000.0) * fontSize;
}
}
// Fallback to missing width if descriptor is available
const auto* desc = getDescriptor();
if (desc && desc->getMissingWidth() > 0.0) {
return (desc->getMissingWidth() / 1000.0) * fontSize;
@@ -198,4 +186,4 @@ protected:
bool has_vertical_metrics_ = false;
};
} // namespace pdfengine::fonts::pdf_fonts
}
+14 -15
View File
@@ -13,7 +13,6 @@ void skipWhitespace(const std::string& str, size_t& pos) {
if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\0') {
pos++;
} else if (c == '%') {
// comment, skip to end of line
while (pos < str.size() && str[pos] != '\n' && str[pos] != '\r') {
pos++;
}
@@ -23,32 +22,32 @@ void skipWhitespace(const std::string& str, size_t& pos) {
}
}
} // namespace
}
bool FontDescriptor::parseFromDictionaryString(const std::string& dictStr) {
size_t pos = 0;
skipWhitespace(dictStr, pos);
if (pos + 2 > dictStr.size() || dictStr[pos] != '<' || dictStr[pos+1] != '<') {
return false; // must start with <<
return false;
}
pos += 2;
while (true) {
skipWhitespace(dictStr, pos);
if (pos >= dictStr.size()) {
return false; // missing closing >>
return false;
}
if (pos + 2 <= dictStr.size() && dictStr[pos] == '>' && dictStr[pos+1] == '>') {
pos += 2;
break; // successfully reached closing >>
break;
}
if (dictStr[pos] != '/') {
return false; // key must start with /
return false;
}
pos++; // skip '/'
pos++;
size_t keyStart = pos;
while (pos < dictStr.size()) {
@@ -61,19 +60,19 @@ bool FontDescriptor::parseFromDictionaryString(const std::string& dictStr) {
pos++;
}
if (pos == keyStart) {
return false; // empty key name
return false;
}
std::string key = dictStr.substr(keyStart, pos - keyStart);
skipWhitespace(dictStr, pos);
if (pos >= dictStr.size()) {
return false; // key with no value
return false;
}
std::string valueStr;
if (dictStr[pos] == '[') {
size_t arrStart = pos;
pos++; // skip '['
pos++;
int depth = 1;
while (pos < dictStr.size() && depth > 0) {
if (dictStr[pos] == '[') depth++;
@@ -81,11 +80,11 @@ bool FontDescriptor::parseFromDictionaryString(const std::string& dictStr) {
pos++;
}
if (depth > 0) {
return false; // unmatched brackets
return false;
}
valueStr = dictStr.substr(arrStart, pos - arrStart);
} else if (dictStr[pos] == '/') {
pos++; // skip '/'
pos++;
size_t valStart = pos;
while (pos < dictStr.size()) {
char c = dictStr[pos];
@@ -157,7 +156,7 @@ bool FontDescriptor::parseFromDictionaryString(const std::string& dictStr) {
idx += processed;
}
if (nums.size() != 4) {
return false; // FontBBox must have 4 coordinates
return false;
}
font_bbox_.llx = static_cast<int>(nums[0]);
font_bbox_.lly = static_cast<int>(nums[1]);
@@ -165,11 +164,11 @@ bool FontDescriptor::parseFromDictionaryString(const std::string& dictStr) {
font_bbox_.ury = static_cast<int>(nums[3]);
}
} catch (const std::exception&) {
return false; // parsing or range exception
return false;
}
}
return true;
}
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -21,7 +21,6 @@ public:
FontDescriptor() = default;
~FontDescriptor() = default;
// Getters and Setters
std::string getFontName() const { return font_name_; }
void setFontName(const std::string& name) { font_name_ = name; }
@@ -64,7 +63,6 @@ public:
double getMissingWidth() const { return missing_width_; }
void setMissingWidth(double missing_width) { missing_width_ = missing_width; }
// Flags helper functions (PDF Spec Section 5.7.1)
bool isFixedPitch() const { return (flags_ & 1) != 0; }
bool isSerif() const { return (flags_ & 2) != 0; }
bool isSymbolic() const { return (flags_ & 4) != 0; }
@@ -75,7 +73,6 @@ public:
bool isSmallCap() const { return (flags_ & 131072) != 0; }
bool isForceBold() const { return (flags_ & 262144) != 0; }
// Parses a PDF dictionary string representing a FontDescriptor.
bool parseFromDictionaryString(const std::string& dictStr);
private:
@@ -95,4 +92,4 @@ private:
double missing_width_ = 0.0;
};
} // namespace pdfengine::fonts::pdf_fonts
}
+1 -6
View File
@@ -13,7 +13,7 @@ std::string toLower(const std::string& str) {
});
return lower;
}
} // namespace
}
FontFallback& FontFallback::getInstance() {
static FontFallback instance;
@@ -157,11 +157,6 @@ std::string FontFallback::getFallbackFontPath(const std::string& fontName, bool
}
}
// Generic bundled catch-all: any font that matched NO specific rule above (an exotic/embedded
// family we don't recognise) still gets a real, present-on-both-engines fallback instead of OS
// Arial (native only) or nothing (WASM -> garble). Pick serif (Tinos) vs sans (Carlito) from the
// name; default sans. Recognised families (helvetica/arial/times/calibri/courier) already
// returned above, so this never overrides them. Keeps WASM == native for every font.
#ifdef PDFENGINE_FONT_DIR
{
auto has = [&](const char* s) { return lowerName.find(s) != std::string::npos; };
+1 -1
View File
@@ -36,4 +36,4 @@ private:
mutable std::mutex rules_mutex_;
};
} // namespace pdfengine::fonts::pdf_fonts
}
+1 -5
View File
@@ -41,7 +41,6 @@ std::unique_ptr<Font> FontLoader::loadType1SystemFallback(
std::unique_ptr<FontDescriptor> descriptor,
std::unique_ptr<Encoding> encoding
) {
// Determine bold/italic style modifiers from name
std::string lowerName = baseFont;
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
@@ -50,7 +49,6 @@ std::unique_ptr<Font> FontLoader::loadType1SystemFallback(
bool bold = (lowerName.find("bold") != std::string::npos);
bool italic = (lowerName.find("italic") != std::string::npos || lowerName.find("oblique") != std::string::npos);
// Resolve system path dynamically via fallback manager
std::string fontPath = FontFallback::getInstance().getFallbackFontPath(baseFont, bold, italic);
auto font = std::make_unique<Type1Font>(baseFont, false, std::move(descriptor), std::move(encoding));
@@ -80,7 +78,6 @@ std::unique_ptr<Font> FontLoader::loadCIDFontSystemFallback(
std::unique_ptr<FontDescriptor> descriptor,
std::unique_ptr<Encoding> encoding
) {
// Determine CJK style modifiers if any
std::string lowerName = baseFont;
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
@@ -88,7 +85,6 @@ std::unique_ptr<Font> FontLoader::loadCIDFontSystemFallback(
bool bold = (lowerName.find("bold") != std::string::npos);
bool italic = (lowerName.find("italic") != std::string::npos || lowerName.find("oblique") != std::string::npos);
// Resolve system path dynamically via fallback manager
std::string fontPath = FontFallback::getInstance().getFallbackFontPath(baseFont, bold, italic);
auto font = std::make_unique<CIDFont>(baseFont, subtype, false, std::move(descriptor), std::move(encoding));
@@ -98,4 +94,4 @@ std::unique_ptr<Font> FontLoader::loadCIDFontSystemFallback(
return font;
}
} // namespace pdfengine::fonts::pdf_fonts
}
+2 -7
View File
@@ -11,7 +11,6 @@ namespace pdfengine::fonts::pdf_fonts {
class FontLoader {
public:
// Factory method to load an embedded TrueType font from its raw stream bytes
static std::unique_ptr<Font> loadTrueTypeFromMemory(
const std::string& baseFont,
const std::vector<uint8_t>& streamData,
@@ -19,7 +18,6 @@ public:
std::unique_ptr<Encoding> encoding = nullptr
);
// Factory method to load an embedded Type1 font from raw memory bytes
static std::unique_ptr<Font> loadType1FromMemory(
const std::string& baseFont,
const std::vector<uint8_t>& streamData,
@@ -27,26 +25,23 @@ public:
std::unique_ptr<Encoding> encoding = nullptr
);
// Factory method to load a non-embedded standard Type1 font (resolves to standard system fallback)
static std::unique_ptr<Font> loadType1SystemFallback(
const std::string& baseFont,
std::unique_ptr<FontDescriptor> descriptor = nullptr,
std::unique_ptr<Encoding> encoding = nullptr
);
// Factory method to load an embedded CIDFont from raw stream bytes
static std::unique_ptr<Font> loadCIDFontFromMemory(
const std::string& baseFont,
FontType subtype, // CIDFontType0 or CIDFontType2
FontType subtype,
const std::vector<uint8_t>& streamData,
std::unique_ptr<FontDescriptor> descriptor = nullptr,
std::unique_ptr<Encoding> encoding = nullptr
);
// Factory method to load a non-embedded CJK CIDFont (resolves to CJK system fallbacks)
static std::unique_ptr<Font> loadCIDFontSystemFallback(
const std::string& baseFont,
FontType subtype, // CIDFontType0 or CIDFontType2
FontType subtype,
std::unique_ptr<FontDescriptor> descriptor = nullptr,
std::unique_ptr<Encoding> encoding = nullptr
);
+2 -18
View File
@@ -16,8 +16,6 @@ void FontSubset::populateFromFace(FontSubset& subset, void* ftFace) {
FT_UInt gindex;
FT_ULong charcode = FT_Get_First_Char(face, &gindex);
while (gindex != 0) {
// In embedded subsets, the TrueType cmap typically maps the
// Original GID or CID (charcode) to the new Subset GID (gindex).
subset.addGlyphMapping(gindex, static_cast<uint32_t>(charcode));
charcode = FT_Get_Next_Char(face, charcode, &gindex);
}
@@ -55,10 +53,8 @@ std::vector<uint8_t> FontSubset::buildSubset(const std::vector<uint8_t>& origina
hb_set_add(glyph_set, gid);
}
// Always keep GID 0 (the .notdef glyph)
hb_set_add(glyph_set, 0);
// Retain layout tables for complex text shaping
hb_subset_input_set_flags(input, HB_SUBSET_FLAGS_RETAIN_GIDS);
hb_face_t* subset_face = hb_subset_or_fail(face, input);
@@ -81,12 +77,6 @@ std::vector<uint8_t> FontSubset::buildSubset(const std::vector<uint8_t>& origina
return result;
}
// Overwrite a few bytes of the sfnt 'name' table strings (family/full/PostScript/typographic
// names) with a deterministic seed so the subset has a UNIQUE name. Two reflow subsets of the
// same base font (e.g. Calibri) otherwise share the name "Calibri", and PDFium merges them —
// making the second subset's text map to the first's glyphs (scrambled output). Identical inputs
// hash to the same seed → same name → safe to share. The name is only used for de-dup; rendering
// uses the embedded glyphs + cmap, so a "garbled" name is harmless.
static void makeFontNameUnique(std::vector<uint8_t>& font, uint64_t seed) {
auto rd16 = [&](size_t o) -> uint32_t { return (o + 1 < font.size()) ? (uint32_t(font[o]) << 8) | font[o + 1] : 0; };
auto rd32 = [&](size_t o) -> uint32_t { return (o + 3 < font.size()) ? (uint32_t(font[o]) << 24) | (uint32_t(font[o + 1]) << 16) | (uint32_t(font[o + 2]) << 8) | font[o + 3] : 0; };
@@ -109,7 +99,7 @@ static void makeFontNameUnique(std::vector<uint8_t>& font, uint64_t seed) {
uint32_t len = rd16(rec + 8);
size_t s = storageOff + rd16(rec + 10);
for (uint32_t b = 0; b < len && b < 8; ++b) {
if (s + b < font.size()) font[s + b] = static_cast<uint8_t>('A' + ((seed >> ((b % 8) * 4)) & 0x0F)); // 'A'..'P'
if (s + b < font.size()) font[s + b] = static_cast<uint8_t>('A' + ((seed >> ((b % 8) * 4)) & 0x0F));
}
}
}
@@ -117,11 +107,6 @@ static void makeFontNameUnique(std::vector<uint8_t>& font, uint64_t seed) {
std::vector<uint8_t> FontSubset::buildSubsetByUnicode(const std::vector<uint8_t>& originalStream,
const std::vector<uint32_t>& codepoints) {
// Subset a font down to only the glyphs needed for `codepoints`, for embedding
// a substitute font compactly (Acrobat-style). Unlike buildSubset (GID-based,
// RETAIN_GIDS for existing subset streams), this drives the subset by UNICODE
// and lets HarfBuzz compact glyph ids + rebuild the cmap, so PDFium's
// FPDFText_SetText (unicode → glyph via cmap) maps correctly into the result.
std::vector<uint8_t> result;
if (originalStream.empty() || codepoints.empty()) {
return result;
@@ -152,7 +137,6 @@ std::vector<uint8_t> FontSubset::buildSubsetByUnicode(const std::vector<uint8_t>
hb_set_add(unicodes, cp);
}
}
// No RETAIN_GIDS flag: compact the glyph ids and rebuild the cmap.
hb_face_t* subset_face = hb_subset_or_fail(face, input);
hb_subset_input_destroy(input);
@@ -223,4 +207,4 @@ bool FontSubset::hasGlyphMapping(uint32_t subsetGid) const {
return subset_to_original_map_.find(subsetGid) != subset_to_original_map_.end();
}
} // namespace pdfengine::fonts::pdf_fonts
}
+1 -5
View File
@@ -16,14 +16,10 @@ public:
static std::string getSubsetPrefix(const std::string& fontName);
// Iterates the FT_Face cmap to populate the mapping dictionary
static void populateFromFace(FontSubset& subset, void* ftFace);
// Rebuilds the TTF/CID stream keeping only the specified GIDs using HarfBuzz
static std::vector<uint8_t> buildSubset(const std::vector<uint8_t>& originalStream, const std::vector<uint32_t>& glyphIdsToKeep);
// Subset a font by the UNICODE codepoints used (compacts GIDs + rebuilds cmap).
// Use when embedding a substitute font for newly-typed text.
static std::vector<uint8_t> buildSubsetByUnicode(const std::vector<uint8_t>& originalStream, const std::vector<uint32_t>& codepoints);
explicit FontSubset(const std::string& fontName);
@@ -45,4 +41,4 @@ private:
std::unordered_map<uint32_t, uint32_t> subset_to_original_map_;
};
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -33,7 +33,7 @@ std::string unicodeToUtf8(uint32_t codepoint) {
return utf8;
}
} // namespace
}
CIDFont::CIDFont(
const std::string& baseFont,
@@ -128,7 +128,6 @@ uint32_t CIDFont::decodeToUnicode(uint32_t charCode) const {
}
}
// Check standard collection DB (e.g. Adobe-Japan1)
if (descriptor_) {
std::string fontName = descriptor_->getFontName();
std::string lowerName = fontName;
@@ -171,7 +170,6 @@ uint32_t CIDFont::decodeToUnicode(uint32_t charCode) const {
}
if (registry != "None") {
// Resolve CJK collections (e.g. matching standard Japanese font mappings)
uint32_t cjkResolved = CjkCollectionDB::resolveCID(registry, charCode);
if (cjkResolved != 0) {
return cjkResolved;
@@ -226,4 +224,4 @@ void CIDFont::buildGidToUnicodeMap() const {
is_gid_to_unicode_map_built_ = true;
}
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -17,17 +17,15 @@ class CIDFont : public Font {
public:
CIDFont(
const std::string& baseFont,
FontType subtype, // CIDFontType0 or CIDFontType2
FontType subtype,
bool isEmbedded,
std::unique_ptr<FontDescriptor> descriptor = nullptr,
std::unique_ptr<Encoding> encoding = nullptr
);
~CIDFont() override;
// Load the font from raw embedded stream bytes
bool loadFromStream(const std::vector<uint8_t>& streamData);
// Load the font from a system or CJK fallback file path
bool loadFromFile(const std::string& filePath);
std::string getBaseFont() const override;
@@ -44,7 +42,6 @@ public:
uint32_t decodeToUnicode(uint32_t charCode) const override;
std::string decodeStringToUnicode(const std::vector<uint32_t>& charCodes) const override;
// CID to GID translation methods
uint32_t mapCIDToGID(uint32_t cid) const;
void setCIDToGIDMap(std::unordered_map<uint32_t, uint32_t> cidToGid);
void setIdentityCIDToGIDMap();
@@ -68,4 +65,4 @@ private:
void buildGidToUnicodeMap() const;
};
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -31,7 +31,7 @@ std::string unicodeToUtf8(uint32_t codepoint) {
return utf8;
}
} // namespace
}
TrueTypeFont::TrueTypeFont(
const std::string& baseFont,
@@ -130,4 +130,4 @@ std::string TrueTypeFont::decodeStringToUnicode(const std::vector<uint32_t>& cha
return result;
}
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -20,7 +20,6 @@ public:
);
~TrueTypeFont() override;
// Load the font from raw embedded stream bytes
bool loadFromStream(const std::vector<uint8_t>& streamData);
std::string getBaseFont() const override;
@@ -46,4 +45,4 @@ private:
std::unique_ptr<FontSubset> subset_info_;
};
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -31,7 +31,7 @@ std::string unicodeToUtf8(uint32_t codepoint) {
return utf8;
}
} // namespace
}
Type1Font::Type1Font(
const std::string& baseFont,
@@ -134,4 +134,4 @@ std::string Type1Font::decodeStringToUnicode(const std::vector<uint32_t>& charCo
return result;
}
} // namespace pdfengine::fonts::pdf_fonts
}
@@ -21,10 +21,8 @@ public:
);
~Type1Font() override;
// Load the font from raw embedded stream bytes
bool loadFromStream(const std::vector<uint8_t>& streamData);
// Load the font from a system or fallback file path
bool loadFromFile(const std::string& filePath);
std::string getBaseFont() const override;
@@ -50,4 +48,4 @@ private:
std::unique_ptr<FontSubset> subset_info_;
};
} // namespace pdfengine::fonts::pdf_fonts
}
+1 -17
View File
@@ -22,60 +22,47 @@ std::vector<ShapedGlyph> HbShaper::shapeRun(
return result;
}
// Acquire lock to prevent concurrent mutation of FT_Face's active pixel size
std::lock_guard<std::mutex> lock(font.getMutex());
// Set the pixel size on the FreeType face before shaping.
// This ensures HarfBuzz measures everything using the requested font size context.
if (FT_Set_Pixel_Sizes(ftFace, 0, fontSize)) {
return result;
}
// Create a HarfBuzz font wrapper around the FreeType face.
hb_font_t* hbFont = hb_ft_font_create_referenced(ftFace);
if (!hbFont) {
return result;
}
// Sync HarfBuzz font with FreeType face changes.
hb_ft_font_changed(hbFont);
// Create a text buffer.
hb_buffer_t* hbBuffer = hb_buffer_create();
if (!hbBuffer) {
hb_font_destroy(hbFont);
return result;
}
// Add text to buffer as UTF-8.
hb_buffer_add_utf8(hbBuffer, text.c_str(), static_cast<int>(text.length()), 0, -1);
// Let HarfBuzz guess direction, script, and language properties.
hb_buffer_guess_segment_properties(hbBuffer);
if (writingMode == WritingMode::Vertical) {
hb_buffer_set_direction(hbBuffer, HB_DIRECTION_TTB);
}
// Enable professional typography features (Kerning and Ligatures)
hb_feature_t features[2];
// Enable kerning
features[0].tag = HB_TAG('k', 'e', 'r', 'n');
features[0].value = 1;
features[0].start = 0;
features[0].end = static_cast<unsigned int>(-1);
// Enable standard ligatures
features[1].tag = HB_TAG('l', 'i', 'g', 'a');
features[1].value = 1;
features[1].start = 0;
features[1].end = static_cast<unsigned int>(-1);
// Shape the text inside the buffer using the font.
hb_shape(hbFont, hbBuffer, features, 2);
// Retrieve the results.
unsigned int glyphCount = 0;
hb_glyph_info_t* glyphInfos = hb_buffer_get_glyph_infos(hbBuffer, &glyphCount);
hb_glyph_position_t* glyphPositions = hb_buffer_get_glyph_positions(hbBuffer, &glyphCount);
@@ -85,8 +72,6 @@ std::vector<ShapedGlyph> HbShaper::shapeRun(
for (unsigned int i = 0; i < glyphCount; ++i) {
ShapedGlyph sg;
sg.glyphIndex = glyphInfos[i].codepoint;
// HarfBuzz coordinates are fractional 26.6 pixels (1/64 of a pixel).
// Convert to standard double-precision float values.
sg.advanceX = static_cast<double>(glyphPositions[i].x_advance) / 64.0;
sg.advanceY = static_cast<double>(glyphPositions[i].y_advance) / 64.0;
sg.offsetX = static_cast<double>(glyphPositions[i].x_offset) / 64.0;
@@ -96,11 +81,10 @@ std::vector<ShapedGlyph> HbShaper::shapeRun(
}
}
// Clean up HarfBuzz resources.
hb_buffer_destroy(hbBuffer);
hb_font_destroy(hbFont);
return result;
}
} // namespace pdfengine::fonts
}
+1 -3
View File
@@ -31,8 +31,6 @@ public:
Vertical
};
// Shapes the input UTF-8 text run using the given FontFace and fontSize.
// Returns a vector of shaped glyphs.
std::vector<ShapedGlyph> shapeRun(
const std::string& text,
FontFace& font,
@@ -41,4 +39,4 @@ public:
);
};
} // namespace pdfengine::fonts
}
+2 -4
View File
@@ -33,7 +33,7 @@ void ContentBuilder::processOperation(const Operation& op, std::vector<std::uniq
handleTf(op);
} else if (op.op == "Td" || op.op == "TD" || op.op == "Tm") {
handleTd(op);
} else if (op.op == "Tj" || op.op == "'") { // ' is equivalent to T* Tj
} else if (op.op == "Tj" || op.op == "'") {
handleTj(op, outObjects);
} else if (op.op == "TJ") {
handleTJ_Array(op, outObjects);
@@ -72,7 +72,6 @@ void ContentBuilder::handleTf(const Operation& op) {
void ContentBuilder::handleTd(const Operation& op) {
if (op.op == "Tm" && op.operands.size() >= 6) {
// Tm takes 6 operands: a b c d e f
auto it = op.operands.end();
auto fNode = *(--it);
auto eNode = *(--it);
@@ -87,7 +86,6 @@ void ContentBuilder::handleTd(const Operation& op) {
if (eNode->type == AstNodeType::Number) state_.tm[4] = eNode->numberValue;
if (fNode->type == AstNodeType::Number) state_.tm[5] = fNode->numberValue;
} else if ((op.op == "Td" || op.op == "TD") && op.operands.size() >= 2) {
// Td simply offsets e and f in the matrix
auto it = op.operands.end();
auto yNode = *(--it);
auto xNode = *(--it);
@@ -306,4 +304,4 @@ void ContentBuilder::handlePathPaint(PathPaintOp paintOp,
currentPath_.clear();
}
} // namespace pdfengine
}
+2 -4
View File
@@ -16,12 +16,11 @@ public:
std::vector<std::unique_ptr<ContentObject>> build(const std::vector<Operation>& operations);
private:
// Graphics State Tracker
struct GraphicsState {
std::string fontName;
double fontSize = 0.0;
double tm[6] = {1.0, 0.0, 0.0, 1.0, 0.0, 0.0};
Matrix ctm; // Current Transformation Matrix
Matrix ctm;
};
GraphicsState state_;
@@ -31,7 +30,6 @@ private:
void processOperation(const Operation& op, std::vector<std::unique_ptr<ContentObject>>& outObjects);
// Handlers
void handleTf(const Operation& op);
void handleTd(const Operation& op);
void handleTj(const Operation& op, std::vector<std::unique_ptr<ContentObject>>& outObjects);
@@ -42,4 +40,4 @@ private:
void handlePathPaint(PathPaintOp paintOp, std::vector<std::unique_ptr<ContentObject>>& outObjects, bool closePath = false);
};
} // namespace pdfengine
}
+1 -15
View File
@@ -11,8 +11,6 @@ std::vector<std::string> ContentStreamParser::tokenize(const std::string& stream
for (size_t i = 0; i < stream.size(); ++i) {
char c = stream[i];
// Simplified tokenization: split by whitespace
// In a real PDF parser, we must handle arrays [], dicts <<>>, strings (), etc.
if (std::isspace(c)) {
if (!current_token.empty()) {
tokens.push_back(current_token);
@@ -46,10 +44,7 @@ void ContentStreamParser::parse(const std::string& contentStream, DisplayList& d
std::vector<std::string> operands;
for (const auto& token : tokens) {
// Simple heuristic: if it starts with a letter (and isn't a PDF name starting with /)
// or is a known operator, treat as operator. Otherwise operand.
if (!token.empty() && std::isalpha(token[0]) && token[0] != '/') {
// It's an operator
if (token == "m") {
if (operands.size() >= 2) {
float y = std::stof(operands.back()); operands.pop_back();
@@ -93,26 +88,17 @@ void ContentStreamParser::parse(const std::string& contentStream, DisplayList& d
displayList.strokePath(m_currentPath);
m_currentPath.clear();
} else if (token == "Do") {
// Draw Image XObject
if (!operands.empty()) {
std::string imageName = operands.back(); operands.pop_back();
// In a full implementation, we would look up 'imageName' in the
// page's /Resources /XObject dictionary, check if /Subtype is /Image,
// apply /Filter /DCTDecode (JPEG decompression), and extract raw pixels.
// For now, we emit a placeholder command if the name is found.
// Dummy ImageInfo for stub
ImageInfo img;
img.width = 100;
img.height = 100;
// Usually the CTM (Current Transformation Matrix) defines the image bounds.
// We just emit a 1x1 image at origin, assuming SetTransformCommand handled bounds.
Matrix m;
displayList.drawImage(img, m, 1.0f);
}
}
// Clear operands for next operator
operands.clear();
} else {
operands.push_back(token);
@@ -120,4 +106,4 @@ void ContentStreamParser::parse(const std::string& contentStream, DisplayList& d
}
}
} // namespace pdfengine
}
+1 -7
View File
@@ -7,22 +7,16 @@
namespace pdfengine {
// A lightweight parser for PDF content streams.
// In Phase 3, this interprets operators and builds the DisplayList.
class ContentStreamParser {
public:
ContentStreamParser() = default;
// Parses the given content stream and appends commands to the display list.
// 'resources' could later be a map of names to images/fonts.
void parse(const std::string& contentStream, DisplayList& displayList);
private:
// Helper to tokenize the content stream
std::vector<std::string> tokenize(const std::string& stream);
// Current path state
Path m_currentPath;
};
} // namespace pdfengine
}
+1 -7
View File
@@ -4,15 +4,12 @@ namespace pdfengine {
Path DecorationBuilder::buildUnderline(float x, float y, float width, float thickness, float offset) {
Path p;
// An underline is essentially a thin filled rectangle at (y + offset).
// The caller is responsible for supplying the correctly signed offset.
p.addRect(x, y + offset, width, thickness);
return p;
}
Path DecorationBuilder::buildStrikeout(float x, float y, float width, float thickness, float offset) {
Path p;
// A strikeout is similarly a thin filled rectangle, positioned higher up.
p.addRect(x, y + offset, width, thickness);
return p;
}
@@ -28,14 +25,11 @@ Path DecorationBuilder::buildSquiggly(float x, float y, float width, float ampli
float currentX = x;
float endX = x + width;
// Create a jagged squiggly line using line segments.
// This is drawn as a stroked path rather than a filled rect.
bool up = true;
while (currentX < endX) {
float nextX = currentX + (frequency / 2.0f);
if (nextX > endX) {
nextX = endX;
// Adjust the final Y to keep the slope somewhat consistent if chopped early
float ratio = (nextX - currentX) / (frequency / 2.0f);
float nextY = y + (up ? amplitude : -amplitude) * ratio;
p.lineTo(nextX, nextY);
@@ -52,4 +46,4 @@ Path DecorationBuilder::buildSquiggly(float x, float y, float width, float ampli
return p;
}
} // namespace pdfengine
}
+1 -17
View File
@@ -4,29 +4,13 @@
namespace pdfengine {
// A utility class to generate vector paths for text markup and decorations
class DecorationBuilder {
public:
// Builds a path representing a straight underline.
// x, y: starting coordinates (usually the baseline origin)
// width: length of the underline
// thickness: thickness of the line (used to build a thin rectangle)
// offset: vertical offset from y
static Path buildUnderline(float x, float y, float width, float thickness = 1.0f, float offset = -2.0f);
// Builds a path representing a strikeout line.
// x, y: starting coordinates (baseline)
// width: length of the strikeout
// thickness: thickness of the line
// offset: vertical offset from y (typically goes up through the text)
static Path buildStrikeout(float x, float y, float width, float thickness = 1.0f, float offset = 4.0f);
// Builds a path representing a squiggly underline (often used for spelling or grammar highlights).
// x, y: starting coordinates
// width: length of the squiggly
// amplitude: height of the squiggly waves
// frequency: horizontal width of a single wave cycle
static Path buildSquiggly(float x, float y, float width, float amplitude = 2.0f, float frequency = 4.0f);
};
} // namespace pdfengine
}
+13 -21
View File
@@ -21,7 +21,7 @@ std::optional<Token> Lexer::nextToken() {
skipWhitespaceAndComments();
if (isEOF()) {
return std::nullopt; // Or we can return EndOfStream token
return std::nullopt;
}
size_t startPos = pos_;
@@ -55,7 +55,6 @@ void Lexer::skipWhitespaceAndComments() {
if (isWhitespace(c)) {
advance();
} else if (c == '%') {
// Skip until end of line
while (!isEOF() && peek() != '\n' && peek() != '\r') {
advance();
}
@@ -67,7 +66,7 @@ void Lexer::skipWhitespaceAndComments() {
std::optional<Token> Lexer::parseString() {
size_t startPos = pos_;
advance(); // skip '('
advance();
std::string str;
int parenLevel = 1;
@@ -95,13 +94,12 @@ std::optional<Token> Lexer::parseString() {
case '(': str += '('; break;
case ')': str += ')'; break;
case '\\': str += '\\'; break;
case '\n': break; // ignored line break
case '\n': break;
case '\r':
if (peek() == '\n') advance();
break;
default:
if (n >= '0' && n <= '7') {
// Octal up to 3 digits
int val = n - '0';
for (int i = 0; i < 2; ++i) {
if (!isEOF() && peek() >= '0' && peek() <= '7') {
@@ -112,7 +110,6 @@ std::optional<Token> Lexer::parseString() {
}
str += static_cast<char>(val);
} else {
// Unknown escape, just output the char
str += n;
}
}
@@ -126,7 +123,7 @@ std::optional<Token> Lexer::parseString() {
std::optional<Token> Lexer::parseHexString() {
size_t startPos = pos_;
advance(); // skip '<'
advance();
std::vector<uint8_t> bytes;
bool hasHigh = false;
@@ -136,7 +133,6 @@ std::optional<Token> Lexer::parseHexString() {
char c = advance();
if (c == '>') {
if (hasHigh) {
// If odd number of hex digits, implicitly append 0
bytes.push_back(high << 4);
}
break;
@@ -145,7 +141,6 @@ std::optional<Token> Lexer::parseHexString() {
int val = hexDigitValue(c);
if (val == -1) {
// Invalid char, usually stop or ignore. We'll ignore for now or break.
continue;
}
@@ -163,7 +158,7 @@ std::optional<Token> Lexer::parseHexString() {
std::optional<Token> Lexer::parseName() {
size_t startPos = pos_;
advance(); // skip '/'
advance();
std::string name;
while (!isEOF()) {
@@ -203,10 +198,9 @@ std::optional<Token> Lexer::parseNumberOrOperator() {
}
if (val.empty()) {
return std::nullopt; // should not happen if we skip properly
return std::nullopt;
}
// Is it a number?
bool isNum = true;
bool hasDot = false;
for (size_t i = 0; i < val.size(); ++i) {
@@ -221,7 +215,6 @@ std::optional<Token> Lexer::parseNumberOrOperator() {
break;
}
}
// A single '+' or '-' or '.' is not a number
if (val == "+" || val == "-" || val == ".") isNum = false;
if (isNum) {
@@ -239,23 +232,22 @@ std::optional<Token> Lexer::parseNumberOrOperator() {
std::optional<Token> Lexer::parseDictOrLess() {
size_t startPos = pos_;
advance(); // skip '<'
advance();
if (!isEOF() && peek() == '<') {
advance(); // skip second '<'
advance();
return Token{TokenType::DictStart, "", {}, 0.0, startPos, pos_};
}
// Shouldn't be called if it was HexString, handled in nextToken()
return Token{TokenType::Operator, "<", {}, 0.0, startPos, pos_}; // fallback
return Token{TokenType::Operator, "<", {}, 0.0, startPos, pos_};
}
std::optional<Token> Lexer::parseDictOrGreater() {
size_t startPos = pos_;
advance(); // skip '>'
advance();
if (!isEOF() && peek() == '>') {
advance(); // skip second '>'
advance();
return Token{TokenType::DictEnd, "", {}, 0.0, startPos, pos_};
}
return Token{TokenType::Operator, ">", {}, 0.0, startPos, pos_}; // fallback
return Token{TokenType::Operator, ">", {}, 0.0, startPos, pos_};
}
char Lexer::peek(size_t offset) const {
@@ -295,4 +287,4 @@ int Lexer::hexDigitValue(char c) {
return -1;
}
} // namespace pdfengine
}
+1 -3
View File
@@ -11,10 +11,8 @@ class Lexer {
public:
explicit Lexer(std::string_view input);
// Retrieve all tokens in one pass
std::vector<Token> tokenize();
// Or retrieve tokens one by one
std::optional<Token> nextToken();
private:
@@ -39,4 +37,4 @@ private:
static int hexDigitValue(char c);
};
} // namespace pdfengine
}
+6 -16
View File
@@ -15,12 +15,10 @@ std::vector<Operation> ContentParser::parse() {
if (token->type == TokenType::Operator) {
Operation op;
op.op = token->stringValue;
// PDF stream operators consume whatever is on the operand stack
op.operands = std::move(operandStack_);
operandStack_.clear();
operations.push_back(std::move(op));
} else {
// It's an operand (or the start of a composite operand)
auto node = parseNode(*token);
if (node) {
operandStack_.push_back(std::move(node));
@@ -74,8 +72,6 @@ std::shared_ptr<AstNode> ContentParser::parseNode(const Token& token) {
case TokenType::DictEnd:
case TokenType::EndOfStream:
case TokenType::Operator:
// These should not be parsed as standalone operand nodes here
// Operators are handled in the main loop, Ends are handled in Array/Dict parsing
return nullptr;
}
@@ -90,18 +86,15 @@ std::shared_ptr<AstNode> ContentParser::parseArray() {
if (!token) break;
if (token->type == TokenType::ArrayEnd) {
advance(); // consume ']'
advance();
break;
}
// Cannot have Operator inside array
if (token->type == TokenType::Operator) {
// PDF spec doesn't strictly allow operators inside arrays,
// but we gracefully break out or skip. We'll break out to avoid infinite loops.
break;
}
advance(); // consume item token
advance();
auto item = parseNode(*token);
if (item) {
node->arrayItems.push_back(std::move(item));
@@ -119,27 +112,24 @@ std::shared_ptr<AstNode> ContentParser::parseDictionary() {
if (!keyToken) break;
if (keyToken->type == TokenType::DictEnd) {
advance(); // consume '>>'
advance();
break;
}
if (keyToken->type != TokenType::Name) {
// Dictionaries must have Name keys. If not, this is a malformed dict.
// We just advance to avoid infinite loop.
advance();
continue;
}
advance(); // consume key
advance();
if (isEOF()) break;
const Token* valToken = peek();
if (valToken->type == TokenType::DictEnd) {
// Incomplete key-value pair
break;
}
advance(); // consume value token
advance();
auto valNode = parseNode(*valToken);
if (valNode) {
node->dictItems[keyToken->stringValue] = std::move(valNode);
@@ -167,4 +157,4 @@ bool ContentParser::isEOF() const {
return pos_ >= tokens_.size() || tokens_[pos_].type == TokenType::EndOfStream;
}
} // namespace pdfengine
}
+1 -1
View File
@@ -32,4 +32,4 @@ public:
explicit ParserError(const std::string& msg) : std::runtime_error(msg) {}
};
} // namespace pdfengine
}
+1 -3
View File
@@ -1,7 +1,6 @@
#include "parser/pdfium_loader.hpp"
#ifdef PDFENGINE_WITH_PDFIUM
// The ONLY place in the codebase a raw PDFium header may be included (Rule R2).
#include <fpdfview.h>
#endif
@@ -17,7 +16,6 @@ bool pdfiumAvailable() noexcept {
void pdfiumInitLibrary() {
#ifdef PDFENGINE_WITH_PDFIUM
// PDFium expects this once per process before any document is opened.
FPDF_InitLibrary();
#endif
}
@@ -28,4 +26,4 @@ void pdfiumDestroyLibrary() {
#endif
}
} // namespace pdfengine::parser
}
+1 -11
View File
@@ -1,20 +1,10 @@
// pdfengine::parser — the PDFium boundary.
//
// Rule R2: this directory (engine/src/parser/) is the ONLY place in the entire
// codebase permitted to include PDFium headers or call raw FPDF_* APIs. CI
// enforces this with scripts/check_pdfium_boundary.* — keep it that way.
#pragma once
namespace pdfengine::parser {
// True if the engine was compiled with PDFENGINE_WITH_PDFIUM, i.e. linked
// against the PDFium static library. False in Phase 0 default builds.
[[nodiscard]] bool pdfiumAvailable() noexcept;
// Global one-time initialisation / teardown of the PDFium library.
// No-ops when built without PDFium. Init must be called before, and destroy
// after, any document parsing.
void pdfiumInitLibrary();
void pdfiumDestroyLibrary();
} // namespace pdfengine::parser
}
+1 -1
View File
@@ -18,4 +18,4 @@ public:
virtual ResolvedXObject resolveXObject(const std::string& name) = 0;
};
} // namespace pdfengine
}
+2 -6
View File
@@ -38,7 +38,7 @@ StreamVerification verifyContentStream(const ExtractedStream& stream) {
return v;
}
} // namespace pdfengine
}
namespace pdfengine::qpdf_layer {
@@ -131,7 +131,6 @@ QpdfExtractor::extractFromQpdf(::QPDF& qpdf, int pageIndex) const {
for (auto streamObj : streams) {
if (!streamObj.isStream()) continue;
// Get filters
QPDFObjectHandle dict = streamObj.getDict();
if (dict.hasKey("/Filter")) {
QPDFObjectHandle filter = dict.getKey("/Filter");
@@ -149,7 +148,6 @@ QpdfExtractor::extractFromQpdf(::QPDF& qpdf, int pageIndex) const {
}
}
// Raw data
try {
Pl_Buffer rawPipeline("raw");
streamObj.pipeStreamData(&rawPipeline, 0, qpdf_dl_none, false);
@@ -159,10 +157,8 @@ QpdfExtractor::extractFromQpdf(::QPDF& qpdf, int pageIndex) const {
result.rawContent.append(reinterpret_cast<const char*>(rawBuf->getBuffer()), rawBuf->getSize());
}
} catch (...) {
// Ignore failure for raw
}
// Decoded data
try {
Pl_Buffer decodedPipeline("decoded");
streamObj.pipeStreamData(&decodedPipeline, 0, qpdf_dl_all, false);
@@ -191,4 +187,4 @@ QpdfExtractor::extractFromQpdf(::QPDF& qpdf, int pageIndex) const {
#endif
}
} // namespace pdfengine::qpdf_layer
}
+1 -1
View File
@@ -35,4 +35,4 @@ private:
extractFromQpdf(::QPDF& qpdf, int pageIndex) const;
};
} // namespace pdfengine::qpdf_layer
}
+1 -1
View File
@@ -42,4 +42,4 @@ ResolvedXObject QpdfResourceResolver::resolveXObject(const std::string& name) {
return resolved;
}
} // namespace pdfengine::qpdf_layer
}
+1 -1
View File
@@ -15,4 +15,4 @@ private:
QPDFObjectHandle resourcesDict_;
};
} // namespace pdfengine::qpdf_layer
}
+1 -7
View File
@@ -30,18 +30,12 @@ QpdfWriter::replacePageStreamAndSave(const std::string& sourcePath,
QPDFPageObjectHelper& page = pages[pageIndex];
// Create a new stream with the updated data
QPDFObjectHandle newStream = QPDFObjectHandle::newStream(&pdf, newStreamData);
// Replace the page's contents stream
// According to QPDF specs, if we pass an array to newStream, it handles it,
// but it's simpler to just set the dictionary's /Contents to the new stream
QPDFObjectHandle pageDict = page.getObjectHandle();
pageDict.replaceKey("/Contents", newStream);
// Write it out
QPDFWriter writer(pdf, destPath.c_str());
// For performance/size we usually compress streams
writer.setStreamDataMode(qpdf_s_compress);
writer.write();
@@ -52,4 +46,4 @@ QpdfWriter::replacePageStreamAndSave(const std::string& sourcePath,
#endif
}
} // namespace pdfengine::qpdf_layer
}
+1 -2
View File
@@ -12,7 +12,6 @@ class QpdfWriter {
public:
QpdfWriter() = default;
// Takes a source PDF, replaces the stream of the given page, and writes to a new destination
[[nodiscard]]
std::expected<void, std::string>
replacePageStreamAndSave(const std::string& sourcePath,
@@ -21,4 +20,4 @@ public:
const std::string& newStreamData) const;
};
} // namespace pdfengine::qpdf_layer
}
+2 -4
View File
@@ -21,12 +21,10 @@ std::string AstSerializer::serialize(const std::vector<Operation>& operations) c
std::string AstSerializer::serializeNode(const AstNode& node) const {
switch (node.type) {
case AstNodeType::Number: {
// Need to drop trailing zeros for integers to save space and match standard PDF
double intPart;
if (std::modf(node.numberValue, &intPart) == 0.0) {
return std::to_string(static_cast<long long>(node.numberValue));
} else {
// Round to 4 decimal places for cleanliness
std::ostringstream out;
out.precision(4);
out << std::fixed << node.numberValue;
@@ -37,7 +35,7 @@ std::string AstSerializer::serializeNode(const AstNode& node) const {
}
}
case AstNodeType::Name:
return "/" + node.stringValue; // simplified, assumes no special chars requiring # hex encoding for now
return "/" + node.stringValue;
case AstNodeType::String:
return "(" + escapeString(node.stringValue) + ")";
@@ -90,4 +88,4 @@ std::string AstSerializer::escapeString(const std::string& str) const {
return res;
}
} // namespace pdfengine
}
+1 -1
View File
@@ -17,4 +17,4 @@ private:
std::string escapeString(const std::string& str) const;
};
} // namespace pdfengine
}
+1 -6
View File
@@ -15,12 +15,10 @@ std::vector<Operation> ContentSerializer::serialize(const std::vector<std::uniqu
}
void ContentSerializer::serializeText(const TextObject& textObj, std::vector<Operation>& outOps) const {
// BT
Operation opBT;
opBT.op = "BT";
outOps.push_back(std::move(opBT));
// /FontName FontSize Tf
if (!textObj.fontName.empty() && textObj.fontSize > 0) {
Operation opTf;
opTf.op = "Tf";
@@ -36,7 +34,6 @@ void ContentSerializer::serializeText(const TextObject& textObj, std::vector<Ope
outOps.push_back(std::move(opTf));
}
// a b c d e f Tm
Operation opTm;
opTm.op = "Tm";
for (int i = 0; i < 6; ++i) {
@@ -46,7 +43,6 @@ void ContentSerializer::serializeText(const TextObject& textObj, std::vector<Ope
}
outOps.push_back(std::move(opTm));
// (text) Tj
Operation opTj;
opTj.op = "Tj";
auto strNode = std::make_shared<AstNode>(AstNodeType::String);
@@ -54,10 +50,9 @@ void ContentSerializer::serializeText(const TextObject& textObj, std::vector<Ope
opTj.operands.push_back(std::move(strNode));
outOps.push_back(std::move(opTj));
// ET
Operation opET;
opET.op = "ET";
outOps.push_back(std::move(opET));
}
} // namespace pdfengine
}
+1 -1
View File
@@ -17,4 +17,4 @@ private:
void serializeText(const TextObject& textObj, std::vector<Operation>& outOps) const;
};
} // namespace pdfengine
}
+12 -44
View File
@@ -1,9 +1,3 @@
// Adobe-grade hit-testing & text selection for PdfPage.
//
// Concrete implementations of PdfPage::orderedGlyphs / hitGlyph / selectRange,
// built on top of the (virtual) extractTextWithBounds(). All maths is in
// page-point space with a top-left origin — identical to the frontend
// TextSelectionModel, so engine and browser selections agree.
#include "pdfengine/pdf_document.hpp"
@@ -21,32 +15,28 @@ struct OGlyph {
int line = 0;
double right = 0.0;
double bottom = 0.0;
double mid = 0.0; // horizontal centre
double mid = 0.0;
};
struct LineBand {
double top = 0.0;
double bottom = 0.0;
double mid = 0.0; // vertical centre of the first glyph
int start = 0; // inclusive glyph index
int end = 0; // exclusive glyph index
double mid = 0.0;
int start = 0;
int end = 0;
};
// Uniform 2D grid over glyph bounding boxes — a true spatial index for the
// point→glyph query, independent of line assignment. This is what makes
// hit-testing exact on overlapping/dense content (diacritics, multi-column,
// rotated runs) where a line-bucketed search would miss.
struct Grid {
double minX = 0.0, minY = 0.0, cell = 1.0;
int cols = 0, rows = 0;
std::vector<std::vector<int>> cells; // size cols*rows; glyph indices per cell
std::vector<std::vector<int>> cells;
[[nodiscard]] bool empty() const { return cols == 0 || rows == 0; }
[[nodiscard]] int at(int c, int r) const { return r * cols + c; }
};
struct SelIndex {
std::vector<OGlyph> glyphs; // reading order
std::vector<OGlyph> glyphs;
std::vector<LineBand> lines;
Grid grid;
};
@@ -58,8 +48,6 @@ bool isSpace(const std::string& t) {
return true;
}
// Cluster raw glyphs into lines (by vertical overlap) then sort each line L→R,
// flattening into a single reading-order array with cached line bands.
SelIndex buildIndex(const std::vector<GlyphBounds>& raw) {
SelIndex idx;
std::vector<GlyphBounds> clean;
@@ -121,7 +109,6 @@ SelIndex buildIndex(const std::vector<GlyphBounds>& raw) {
idx.lines.push_back(band);
}
// Build the spatial grid over the final glyph set.
Grid& grid = idx.grid;
double minX = std::numeric_limits<double>::max();
double minY = std::numeric_limits<double>::max();
@@ -135,8 +122,6 @@ SelIndex buildIndex(const std::vector<GlyphBounds>& raw) {
maxY = std::max(maxY, og.bottom);
sumH += og.g.h;
}
// Cell ≈ average glyph height (~one text line), so most cells hold a handful
// of glyphs. Grow the cell if the grid would otherwise be unreasonably large.
double cell = std::max(1.0, sumH / static_cast<double>(idx.glyphs.size()));
auto dim = [&](double lo, double hi) {
return std::max(1, static_cast<int>((hi - lo) / cell) + 1);
@@ -167,15 +152,7 @@ SelIndex buildIndex(const std::vector<GlyphBounds>& raw) {
return idx;
}
// --- spatial index queries (Adobe-grade, fast on dense/overlapping content) --
//
// Point→glyph uses the 2D grid (exact regardless of line layout). Caret/line
// positioning uses the line bands via binary search (lines are in ascending
// vertical order; each line's glyphs are contiguous and x-sorted). Both stay
// cheap on pages with tens of thousands of glyphs.
// Exact glyph under a point via the spatial grid — never misses a containing
// glyph, even where text lines overlap.
int glyphAt(const SelIndex& idx, double x, double y) {
const Grid& grid = idx.grid;
if (grid.empty() || x < grid.minX || y < grid.minY) return -1;
@@ -193,7 +170,6 @@ int lineAt(const SelIndex& idx, double y) {
const auto& lines = idx.lines;
if (lines.empty()) return -1;
// Binary search for the last line whose top <= y.
int lo = 0;
int hi = static_cast<int>(lines.size());
while (lo < hi) {
@@ -201,11 +177,8 @@ int lineAt(const SelIndex& idx, double y) {
if (lines[m].top <= y) lo = m + 1;
else hi = m;
}
const int cand = lo - 1; // -1 when y is above every line
const int cand = lo - 1;
// Check the immediate neighbourhood for true containment, else take the
// nearest band by centre. Bands can overlap slightly (sub/superscript
// tolerance), so a ±1 window around the search boundary is enough.
int best = -1;
double bestDist = std::numeric_limits<double>::max();
for (int i = cand - 1; i <= cand + 1; ++i) {
@@ -221,8 +194,6 @@ int lineAt(const SelIndex& idx, double y) {
return cand < 0 ? 0 : static_cast<int>(lines.size()) - 1;
}
// Index of the last glyph in [start,end) whose left edge x <= queryX, or
// start-1 if queryX is left of the whole line. Glyphs are sorted by x.
static int lastGlyphLeftOf(const SelIndex& idx, const LineBand& line, double x) {
int lo = line.start;
int hi = line.end;
@@ -235,13 +206,11 @@ static int lastGlyphLeftOf(const SelIndex& idx, const LineBand& line, double x)
}
int caretAt(const SelIndex& idx, double x, double y) {
// If the point lands on a glyph, the caret sits on its near or far side.
const int hit = glyphAt(idx, x, y);
if (hit >= 0) {
const OGlyph& g = idx.glyphs[hit];
return x < g.mid ? hit : hit + 1;
}
// Otherwise position within the nearest line (gaps / between lines / margins).
const int li = lineAt(idx, y);
if (li < 0) return 0;
const LineBand& line = idx.lines[li];
@@ -251,8 +220,8 @@ int caretAt(const SelIndex& idx, double x, double y) {
const int cand = lastGlyphLeftOf(idx, line, x);
if (cand < line.start) return line.start;
const OGlyph& g = idx.glyphs[cand];
if (x <= g.right) return x < g.mid ? cand : cand + 1; // inside the glyph
return cand + 1; // in the gap after it
if (x <= g.right) return x < g.mid ? cand : cand + 1;
return cand + 1;
}
std::string textOfRange(const SelIndex& idx, int start, int end) {
@@ -278,8 +247,7 @@ std::string textOfRange(const SelIndex& idx, int start, int end) {
std::vector<GlyphBounds> rectsOfRange(const SelIndex& idx, int start, int end) {
std::vector<GlyphBounds> rects;
if (start >= end) return rects;
// Group selected glyphs by line, preserving first-seen order.
std::map<int, std::pair<double, double>> spanByLine; // line -> {minX, maxRight}
std::map<int, std::pair<double, double>> spanByLine;
std::vector<int> order;
for (int i = start; i < end; ++i) {
const OGlyph& g = idx.glyphs[i];
@@ -307,7 +275,7 @@ std::vector<GlyphBounds> rectsOfRange(const SelIndex& idx, int start, int end) {
return rects;
}
} // namespace
}
std::expected<std::vector<GlyphBounds>, EngineError> PdfPage::orderedGlyphs() const {
auto raw = extractTextWithBounds();
@@ -346,4 +314,4 @@ PdfPage::selectRange(double ax, double ay, double bx, double by) const {
return sel;
}
} // namespace pdfengine
}
+1 -3
View File
@@ -32,7 +32,7 @@ const PathObject* requirePathObject(const std::unique_ptr<ContentObject>& object
return static_cast<const PathObject*>(object.get());
}
} // namespace
}
TEST(ContentBuilderTest, SimpleTextState) {
Lexer lexer("10 20 Td /F1 12 Tf (Hello) Tj");
@@ -67,7 +67,6 @@ TEST(ContentBuilderTest, KerningArrayTJ) {
EXPECT_EQ(objects[0]->getType(), ContentObjectType::Text);
auto* textObj = static_cast<TextObject*>(objects[0].get());
// -600 is less than -500, so it inserts a space
EXPECT_EQ(textObj->text, "Hello World");
}
@@ -202,7 +201,6 @@ TEST(ContentBuilderTest, IntegrationHelloWorld) {
auto ops = parser.parse();
auto objects = builder.build(ops);
// hello_world.pdf has two text lines: "Hello, world!" and "Goodbye, world!"
int textObjectCount = 0;
bool foundHello = false;
bool foundGoodbye = false;
-3
View File
@@ -5,7 +5,6 @@
using namespace pdfengine;
// A simple visitor for testing that just records the sequence of visited commands.
class MockVisitor : public CommandVisitor {
public:
std::vector<std::string> calls;
@@ -25,7 +24,6 @@ TEST(DisplayListTest, RecordAndReplay) {
EXPECT_EQ(list.size(), 0);
// Record some commands
list.saveState();
list.setTransform(Matrix(2.0f, 0.0f, 0.0f, 2.0f, 0.0f, 0.0f));
list.fillRect(10.0f, 10.0f, 100.0f, 50.0f);
@@ -34,7 +32,6 @@ TEST(DisplayListTest, RecordAndReplay) {
EXPECT_EQ(list.size(), 5);
// Replay to the mock visitor
MockVisitor visitor;
list.replay(visitor);
+19 -124
View File
@@ -264,7 +264,7 @@ TEST(PageRenderTest, InvalidPageIndexReturnsPageOutOfBounds) {
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto pageRes = (*docRes)->getPage(1); // Page index 1 is out of bounds for 1-page doc
auto pageRes = (*docRes)->getPage(1);
ASSERT_FALSE(pageRes.has_value());
EXPECT_EQ(pageRes.error(), EngineError::PageOutOfBounds);
@@ -447,7 +447,6 @@ TEST(DocumentEditTest, ApplyRedactionAndFullSave) {
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
// Verify text exists initially
{
auto pageRes = doc->getPage(0);
ASSERT_TRUE(pageRes.has_value());
@@ -456,7 +455,6 @@ TEST(DocumentEditTest, ApplyRedactionAndFullSave) {
EXPECT_NE(textRes->find("Hello"), std::string::npos);
}
// Redact the entire page bounds to remove all objects
std::string editsJson = R"({
"version": "1.0",
"operations": [
@@ -494,7 +492,6 @@ TEST(DocumentEditTest, ApplyRedactionAndFullSave) {
auto textRes = newPage->extractText();
ASSERT_TRUE(textRes.has_value());
// The text should be completely gone
EXPECT_EQ(textRes->find("Hello"), std::string::npos);
EXPECT_EQ(textRes->find("world"), std::string::npos);
}
@@ -562,7 +559,6 @@ TEST(DocumentEditTest, ApplyPageRotationAndIncrementalSave) {
EXPECT_GT(origW, 0.0);
EXPECT_GT(origH, origW);
// 1. Rotate by 90 degrees (90 total)
std::string editsJson1 = R"({
"version": "1.0",
"operations": [
@@ -580,7 +576,6 @@ TEST(DocumentEditTest, ApplyPageRotationAndIncrementalSave) {
auto editRes1 = doc->applyEdits(editsJson1);
ASSERT_TRUE(editRes1.has_value());
// 2. Rotate by another 90 degrees (180 total)
std::string editsJson2 = R"({
"version": "1.0",
"operations": [
@@ -598,7 +593,6 @@ TEST(DocumentEditTest, ApplyPageRotationAndIncrementalSave) {
auto editRes2 = doc->applyEdits(editsJson2);
ASSERT_TRUE(editRes2.has_value());
// Save and load back to verify 180 degree rotation (dimensions should be original again)
auto saveRes = doc->saveIncremental();
ASSERT_TRUE(saveRes.has_value());
const auto& savedBytes = *saveRes;
@@ -617,7 +611,6 @@ TEST(DocumentEditTest, ApplyPageRotationAndIncrementalSave) {
EXPECT_NEAR(rotatedW, origW, 0.01);
EXPECT_NEAR(rotatedH, origH, 0.01);
// 3. Now rotate by -90 degrees (back to 90 total)
std::string editsJson3 = R"({
"version": "1.0",
"operations": [
@@ -700,7 +693,6 @@ TEST(DocumentEditTest, ApplyPageReorderAndIncrementalSave) {
auto doc = *docRes;
EXPECT_EQ(doc->pageCount(), 2);
// Swap the pages: move page 1 (index 1) to page 0 (index 0)
std::string editsJson = R"({
"version": "1.0",
"operations": [
@@ -861,7 +853,6 @@ TEST(FontDiagnosticsTest, ConcurrencyThreadSafety) {
TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
SKIP_IF_NO_PDFIUM();
// Test Part 1: Introspection & Metadata verification using utf-8.pdf
{
auto path = getCorpusPath("fonts", "utf-8.pdf");
if (std::filesystem::exists(path)) {
@@ -874,13 +865,11 @@ TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
auto fonts = *fontsRes;
for (const auto& f : fonts) {
// Core fields
EXPECT_FALSE(f.fontName.empty());
EXPECT_FALSE(f.type.empty());
EXPECT_FALSE(f.normalizedFamily.empty());
EXPECT_FALSE(f.internalFontId.empty());
// Subset tagging consistency
if (f.isSubset) {
EXPECT_EQ(f.subsetTag.size(), 6);
for (char c : f.subsetTag) {
@@ -894,7 +883,6 @@ TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
EXPECT_EQ(f.internalFontId, f.fontName + "_" + f.type + "_" + std::to_string(f.flags));
}
// Source Type / Fallbacks & Substitutions consistency
if (f.sourceType == "SystemFallback") {
EXPECT_FALSE(f.isEmbedded);
EXPECT_TRUE(f.substitutedFrom.empty());
@@ -909,7 +897,6 @@ TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
#endif
}
// Check descriptor metrics are non-zero / reasonably set
EXPECT_GT(f.ascent, 0.0);
EXPECT_LT(f.descent, 0.0);
EXPECT_GT(f.capHeight, 0.0);
@@ -917,7 +904,6 @@ TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
}
}
// Test Part 2: Vertical Writing Mode Detection
{
auto path = getCorpusPath("fonts", "vertical_text.pdf");
if (std::filesystem::exists(path)) {
@@ -935,12 +921,10 @@ TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
EXPECT_TRUE(f.encoding.find("-V") != std::string::npos || f.cmapName.find("-V") != std::string::npos);
}
}
// Ensure at least one vertical font is found in vertical_text.pdf
EXPECT_TRUE(foundVertical);
}
}
// Test Part 2b: Vertical Font Detection Heuristic explicit validation
{
auto path1 = getCorpusPath("fonts", "vertical_identity_v.pdf");
if (std::filesystem::exists(path1)) {
@@ -957,7 +941,6 @@ TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
}
}
// Test Part 3: Font Size and Glyph Bounds Handling
{
auto path = getCorpusPath("fonts", "utf-8.pdf");
if (!std::filesystem::exists(path)) {
@@ -980,20 +963,18 @@ TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
std::vector<double> uniqueSizes;
for (const auto& glyph : glyphs) {
// Ensure glyph bounding box and font sizes are valid positive numbers
if (glyph.text != " " && glyph.text != "\r" && glyph.text != "\n" && glyph.text != "\t") {
EXPECT_GT(glyph.w, 0.0);
EXPECT_GT(glyph.h, 0.0);
}
EXPECT_GT(glyph.fontSize, 0.0);
EXPECT_LT(glyph.fontSize, 100.0); // No absurdly large font sizes
EXPECT_LT(glyph.fontSize, 100.0);
if (std::find(uniqueSizes.begin(), uniqueSizes.end(), glyph.fontSize) == uniqueSizes.end()) {
uniqueSizes.push_back(glyph.fontSize);
}
}
// If it's utf-8.pdf, it should have multiple distinct font sizes
if (path.filename().string() == "utf-8.pdf") {
EXPECT_GE(uniqueSizes.size(), 2u);
}
@@ -1001,23 +982,10 @@ TEST(FontDiagnosticsTest, DeepIntrospectionAndFontSizeVerification) {
}
}
// =========================================================================
// Tests validating real PDFium font dictionary introspection.
// These tests verify that isEmbedded, type, ascent, descent, capHeight, and
// hasToUnicode are now derived from actual PDF font objects rather than from
// font-name heuristics (the behaviour that predated this change).
// =========================================================================
// Verify that embedded fonts report isEmbedded=true and that sourceType is
// set to "Embedded" from the real FPDFFont_GetIsEmbedded() result.
// A subset-embedded font (ABCDEF+FontName prefix) is the clearest case
// because the old heuristic relied solely on the prefix tag for embedding
// detection, while real PDFium checks for the /FontFile stream.
TEST(FontDiagnosticsTest, RealPDFiumEmbeddingAndTypeAccuracy) {
SKIP_IF_NO_PDFIUM();
// text_font.pdf has an embedded subset TrueType font \u2014 best candidate for
// verifying that isEmbedded comes from the PDF font stream, not the name tag.
auto path = getCorpusPath("fonts", "text_font.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "text_font.pdf not found in corpus.";
@@ -1035,11 +1003,9 @@ TEST(FontDiagnosticsTest, RealPDFiumEmbeddingAndTypeAccuracy) {
ASSERT_FALSE(fonts.empty()) << "text_font.pdf must expose at least one font";
for (const auto& f : fonts) {
// Core invariant: every font must have a non-empty name and type.
EXPECT_FALSE(f.fontName.empty());
EXPECT_FALSE(f.type.empty());
// type must be one of the four valid PDF font subtypes.
static const std::vector<std::string> kValidTypes = {
"Type1", "TrueType", "CIDFontType0", "CIDFontType2"
};
@@ -1047,8 +1013,6 @@ TEST(FontDiagnosticsTest, RealPDFiumEmbeddingAndTypeAccuracy) {
!= kValidTypes.end();
EXPECT_TRUE(typeValid) << "Unexpected type '" << f.type << "' for font '" << f.fontName << "'";
// Subset-prefixed fonts MUST be reported as embedded by PDFium
// (the /FontFile stream is required by the PDF spec when a subset tag is present).
if (f.isSubset) {
EXPECT_TRUE(f.isEmbedded)
<< "Subset font '" << f.fontName
@@ -1059,21 +1023,15 @@ TEST(FontDiagnosticsTest, RealPDFiumEmbeddingAndTypeAccuracy) {
EXPECT_TRUE(f.substitutedTo.empty());
}
// isEmbedded=true and sourceType="Embedded" must be consistent.
if (f.isEmbedded) {
EXPECT_EQ(f.sourceType, "Embedded");
}
}
}
// Verify that font descriptor metrics (ascent, descent, capHeight) come from
// the real PDF FontDescriptor via FPDFFont_GetAscent/Descent(), not from the
// hardcoded fallback table. The critical invariant is sign correctness:
// ascent must be positive, descent must be negative.
TEST(FontDiagnosticsTest, RealPDFiumMetricsAccuracy) {
SKIP_IF_NO_PDFIUM();
// Use the largest font corpus file; it contains the most diverse fonts.
auto path = getCorpusPath("fonts", "text_font.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "text_font.pdf not found in corpus.";
@@ -1085,9 +1043,6 @@ TEST(FontDiagnosticsTest, RealPDFiumMetricsAccuracy) {
ASSERT_TRUE(fontsRes.has_value());
for (const auto& f : *fontsRes) {
// ascent and descent from FPDFFont_GetAscent/Descent(font, 1000.0f, …)
// are in PDF 1000-unit space. ascent is above the baseline (positive),
// descent is below (negative).
EXPECT_GT(f.ascent, 0.0)
<< "ascent must be positive for font '" << f.fontName << "'";
EXPECT_LT(f.descent, 0.0)
@@ -1095,30 +1050,17 @@ TEST(FontDiagnosticsTest, RealPDFiumMetricsAccuracy) {
EXPECT_GT(f.capHeight, 0.0)
<< "capHeight must be positive for font '" << f.fontName << "'";
// capHeight must not exceed ascent (sanity: caps never taller than ascender).
EXPECT_LE(f.capHeight, f.ascent + 1.0) // +1 for float rounding
EXPECT_LE(f.capHeight, f.ascent + 1.0)
<< "capHeight should not exceed ascent for font '" << f.fontName << "'";
// Values must be in a plausible PDF 1000-unit-space range.
// Standard fonts typically have ascent in [400, 1200].
EXPECT_LT(f.ascent, 1500.0) << "Implausibly large ascent for '" << f.fontName << "'";
EXPECT_GT(f.descent, -1500.0) << "Implausibly deep descent for '" << f.fontName << "'";
}
}
// Verify that hasToUnicode reflects actual Unicode decode capability rather
// than the old always-true heuristic.
//
// with_tounicode.pdf \u2014 PDF containing a font that has a /ToUnicode stream;
// PDFium should decode characters successfully.
// no_tounicode.pdf \u2014 PDF containing a font with no /ToUnicode stream and no
// standard encoding; PDFium cannot map char codes to Unicode.
// latin_extended.pdf \u2014 Standard Latin font; must decode to Unicode via built-in
// encoding (WinAnsiEncoding or similar).
TEST(FontDiagnosticsTest, ToUnicodePresenceAccuracy) {
SKIP_IF_NO_PDFIUM();
// Case 1: font WITH ToUnicode \u2014 hasToUnicode must be true
{
auto path = getCorpusPath("fonts", "with_tounicode.pdf");
if (std::filesystem::exists(path)) {
@@ -1140,7 +1082,6 @@ TEST(FontDiagnosticsTest, ToUnicodePresenceAccuracy) {
}
}
// Case 2: font WITHOUT ToUnicode or decodable encoding \u2014 hasToUnicode must be false
{
auto path = getCorpusPath("fonts", "no_tounicode.pdf");
if (std::filesystem::exists(path)) {
@@ -1150,7 +1091,6 @@ TEST(FontDiagnosticsTest, ToUnicodePresenceAccuracy) {
if (pageRes.has_value()) {
auto fontsRes = (*pageRes)->getFonts();
if (fontsRes.has_value() && !fontsRes->empty()) {
// All fonts in a no-tounicode document should fail Unicode decode.
for (const auto& f : *fontsRes) {
EXPECT_FALSE(f.hasToUnicode)
<< "Font '" << f.fontName
@@ -1162,7 +1102,6 @@ TEST(FontDiagnosticsTest, ToUnicodePresenceAccuracy) {
}
}
// Case 3: standard Latin font \u2014 must decode to Unicode via built-in encoding
{
auto path = getCorpusPath("fonts", "latin_extended.pdf");
if (std::filesystem::exists(path)) {
@@ -1184,25 +1123,18 @@ TEST(FontDiagnosticsTest, ToUnicodePresenceAccuracy) {
}
}
}
// =========================================================================
// Tests for UTF-16 Surrogate Pairs (Emoji and CJK Ext-B)
// =========================================================================
TEST(UtfConversionTest, EmojiSurrogatePairs) {
// 😀 U+1F600 -> UTF-8: F0 9F 98 80
std::string utf8_grinning = "\xF0\x9F\x98\x80";
auto utf16 = pdfengine::parser::utf8_to_utf16le(utf8_grinning);
// Should be D83D DE00 + null terminator
ASSERT_EQ(utf16.size(), 3);
EXPECT_EQ(utf16[0], 0xD83D);
EXPECT_EQ(utf16[1], 0xDE00);
EXPECT_EQ(utf16[2], 0x0000);
// Convert back to UTF-8
std::string utf8_out = pdfengine::parser::utf16le_to_utf8(reinterpret_cast<const char16_t*>(utf16.data()), utf16.size() - 1);
EXPECT_EQ(utf8_out, utf8_grinning);
// 🚀 U+1F680 -> UTF-8: F0 9F 9A 80
std::string utf8_rocket = "\xF0\x9F\x9A\x80";
utf16 = pdfengine::parser::utf8_to_utf16le(utf8_rocket);
ASSERT_EQ(utf16.size(), 3);
@@ -1215,76 +1147,58 @@ TEST(UtfConversionTest, EmojiSurrogatePairs) {
}
TEST(UtfConversionTest, CJKExtensionB) {
// 𠀀 U+20000 -> UTF-8: F0 A0 80 80
std::string utf8_cjk = "\xF0\xA0\x80\x80";
auto utf16 = pdfengine::parser::utf8_to_utf16le(utf8_cjk);
// Should be D840 DC00 + null terminator
ASSERT_EQ(utf16.size(), 3);
EXPECT_EQ(utf16[0], 0xD840);
EXPECT_EQ(utf16[1], 0xDC00);
EXPECT_EQ(utf16[2], 0x0000);
// Convert back to UTF-8
std::string utf8_out = pdfengine::parser::utf16le_to_utf8(reinterpret_cast<const char16_t*>(utf16.data()), utf16.size() - 1);
EXPECT_EQ(utf8_out, utf8_cjk);
}
TEST(UtfConversionTest, RoundtripMixed) {
// "A😀B𠀀C" -> 41 F0 9F 98 80 42 F0 A0 80 80 43
std::string mixed = "A\xF0\x9F\x98\x80""B\xF0\xA0\x80\x80""C";
auto utf16 = pdfengine::parser::utf8_to_utf16le(mixed);
std::string mixed_out = pdfengine::parser::utf16le_to_utf8(reinterpret_cast<const char16_t*>(utf16.data()), utf16.size() - 1);
EXPECT_EQ(mixed_out, mixed);
}
// =========================================================================
// Tests for CJK CID Resolution
// =========================================================================
TEST(CjkResolutionTest, AdobeCNS1) {
// Basic mapping checks for the core Adobe-CNS1 block (Traditional Chinese)
using pdfengine::fonts::pdf_fonts::CjkCollectionDB;
// Test existing block (100-130)
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 100), 0x4E00); // 一
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 112), 0x4E2D); // 中
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 130), 0x4ED7); // 仗
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 100), 0x4E00);
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 112), 0x4E2D);
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 130), 0x4ED7);
// Test the newly expanded block (131-140)
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 131), 0x4ED8); // 付
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 135), 0x4EDF); // 仟
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 140), 0x4F01); // 企
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 131), 0x4ED8);
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 135), 0x4EDF);
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-CNS1", 140), 0x4F01);
// Test alternative naming
EXPECT_EQ(CjkCollectionDB::resolveCID("Identity-H-CNS1", 137), 0x4EE3); // 代
EXPECT_EQ(CjkCollectionDB::resolveCID("Identity-H-CNS1", 137), 0x4EE3);
}
TEST(CjkResolutionTest, AdobeKorea1) {
using pdfengine::fonts::pdf_fonts::CjkCollectionDB;
// Test existing Korean block (101-150)
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 101), 0xAC00); // 가
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 119), 0xAC1C); // 개
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 150), 0xAC90); // 겔
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 101), 0xAC00);
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 119), 0xAC1C);
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 150), 0xAC90);
// Test newly added Hangul block (151-160)
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 151), 0xAC94); // 겝
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 156), 0xACA9); // 결
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 160), 0xACBD); // 겼
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 151), 0xAC94);
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 156), 0xACA9);
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Korea1", 160), 0xACBD);
// Test alternative naming
EXPECT_EQ(CjkCollectionDB::resolveCID("UniKS-UTF16-H-Korea1", 153), 0xACA0); // 겠
EXPECT_EQ(CjkCollectionDB::resolveCID("UniKS-UTF16-H-Korea1", 153), 0xACA0);
}
// =========================================================================
// GlyphCache Concurrency & Benchmark Test
// =========================================================================
TEST(GlyphCacheTest, ConcurrencyBench) {
using namespace pdfengine::fonts;
FontFace face;
// Load a common system font for testing cache keys
bool loaded = face.loadFromFile("C:\\Windows\\Fonts\\arial.ttf");
if (!loaded) {
GTEST_SKIP() << "Skipping benchmark: Arial font not found.";
@@ -1323,7 +1237,7 @@ TEST(GlyphCacheTest, ConcurrencyBench) {
return diff.count();
};
run_benchmark(2, 1000); // warmup
run_benchmark(2, 1000);
cache.clear();
double time_10 = run_benchmark(10, 10000);
@@ -1334,7 +1248,7 @@ TEST(GlyphCacheTest, ConcurrencyBench) {
double time_50 = run_benchmark(50, 10000);
std::cout << "[ BENCHMARK ] 50 Threads Time: " << time_50 << " seconds (" << (500000.0 / time_50) << " ops/sec)\n";
EXPECT_LE(cache.size(), 1000 + 16); // Accommodate shard capacity rounding
EXPECT_LE(cache.size(), 1000 + 16);
}
TEST(FontDiagnosticsTest, EmbeddedFontResolutionAndReloadingVerification) {
@@ -1415,12 +1329,10 @@ TEST(FontDiagnosticsTest, EmbeddedFontResolutionAndReloadingVerification) {
std::cout << std::endl;
}
// Restore first charmap
if (face->num_charmaps > 0) {
FT_Set_Charmap(face, face->charmaps[0]);
}
// Print all glyph names in the face
std::cout << " Glyph names: ";
for (int i = 0; i < face->num_glyphs; ++i) {
char nameBuf[64] = {0};
@@ -1437,7 +1349,6 @@ TEST(FontDiagnosticsTest, EmbeddedFontResolutionAndReloadingVerification) {
EXPECT_TRUE(resolvedFont->isEmbedded());
// Let's test a few common characters: 'A' (65), 'a' (97), '0' (48), ' ' (32)
std::vector<uint32_t> testChars = {32, 48, 65, 97};
for (uint32_t cp : testChars) {
bool hasG = resolvedFont->hasGlyph(cp);
@@ -1446,23 +1357,19 @@ TEST(FontDiagnosticsTest, EmbeddedFontResolutionAndReloadingVerification) {
<< ", advanceWidth=" << w << std::endl;
}
// Verify metrics returned are non-zero/valid
auto metrics = resolvedFont->getMetrics(12.0);
std::cout << " Metrics: ascent=" << metrics.ascent << ", descent=" << metrics.descent << ", capHeight=" << metrics.capHeight << std::endl;
EXPECT_NE(metrics.ascent, 0.0);
EXPECT_NE(metrics.descent, 0.0);
EXPECT_NE(metrics.capHeight, 0.0);
// Specific verification for text_font.pdf where we mapped charcode 1 -> GID 1
if (fileName == "text_font.pdf") {
// hasGlyph(1) should return true because the charmap maps 1 -> 1
EXPECT_TRUE(resolvedFont->hasGlyph(1));
double w = resolvedFont->getAdvanceWidth(1, 12.0);
EXPECT_GT(w, 0.0);
std::cout << " [VERIFIED] text_font.pdf char(1): hasGlyph=yes, advanceWidth=" << w << std::endl;
}
// Verify we can load glyphs directly by glyph index (0 to num_glyphs - 1)
if (face->num_glyphs > 1) {
bool foundNonZeroWidth = false;
for (int gid = 1; gid < face->num_glyphs; ++gid) {
@@ -1525,7 +1432,6 @@ TEST(DocumentEditTest, ReplaceTextMVPStandardFont) {
if (i + 1 < objectIndices.size()) indicesStr += ",";
}
// Flat format payload
std::string editsJson = R"({
"version": "1.0",
"operations": [
@@ -1604,7 +1510,6 @@ TEST(DocumentEditTest, ReplaceTextRuntimeFontEngine) {
if (i + 1 < objectIndices.size()) indicesStr += ",";
}
// JSON payload including internalFontId to resolve
std::string editsJson = R"({
"version": "1.0",
"operations": [
@@ -1659,7 +1564,6 @@ TEST(DocumentEditTest, ReplaceTextFontReuseAndEmbedding) {
ASSERT_TRUE(modelRes.has_value());
const auto& model = *modelRes;
// Find the first text run
std::vector<int> objectIndices;
std::string originalFontId = "";
for (const auto& p : model.paragraphs) {
@@ -1684,7 +1588,6 @@ TEST(DocumentEditTest, ReplaceTextFontReuseAndEmbedding) {
if (i + 1 < objectIndices.size()) indicesStr += ",";
}
// JSON payload containing replacement using system font embedding
std::string editsJson = R"({
"version": "1.0",
"operations": [
@@ -1702,7 +1605,6 @@ TEST(DocumentEditTest, ReplaceTextFontReuseAndEmbedding) {
auto editRes = doc->applyEdits(editsJson);
ASSERT_TRUE(editRes.has_value());
// Save and reload
auto saveRes = doc->saveIncremental();
ASSERT_TRUE(saveRes.has_value());
const auto& savedBytes = *saveRes;
@@ -1712,7 +1614,6 @@ TEST(DocumentEditTest, ReplaceTextFontReuseAndEmbedding) {
ASSERT_TRUE(newDocRes.has_value());
auto newDoc = *newDocRes;
// Get page fonts to verify that Arial was successfully embedded in the new document
auto fontsRes = newDoc->getFonts(0, 0);
ASSERT_TRUE(fontsRes.has_value());
@@ -1745,7 +1646,6 @@ TEST(DocumentEditTest, ReplaceTextHarfBuzzShapingAndReflow) {
ASSERT_TRUE(modelRes.has_value());
const auto& model = *modelRes;
// Find a line that has at least 2 runs, where the first run uses Roboto-Regular
std::vector<int> targetIndices;
std::string originalFontId = "";
std::string runBText = "";
@@ -1782,7 +1682,6 @@ TEST(DocumentEditTest, ReplaceTextHarfBuzzShapingAndReflow) {
if (i + 1 < targetIndices.size()) indicesStr += ",";
}
// JSON payload: replacing runA with a very long text to trigger significant shift
std::string editsJson = R"({
"version": "1.0",
"operations": [
@@ -1800,7 +1699,6 @@ TEST(DocumentEditTest, ReplaceTextHarfBuzzShapingAndReflow) {
auto editRes = doc->applyEdits(editsJson);
ASSERT_TRUE(editRes.has_value());
// Save and reload
auto saveRes = doc->saveIncremental();
ASSERT_TRUE(saveRes.has_value());
const auto& savedBytes = *saveRes;
@@ -1818,7 +1716,6 @@ TEST(DocumentEditTest, ReplaceTextHarfBuzzShapingAndReflow) {
ASSERT_TRUE(newModelRes.has_value());
const auto& newModel = *newModelRes;
// Find runB in the new document model and verify its X coordinate has shifted to the right
bool foundRunB = false;
double runBNewX = 0.0;
for (const auto& p : newModel.paragraphs) {
@@ -1842,5 +1739,3 @@ TEST(DocumentEditTest, ReplaceTextHarfBuzzShapingAndReflow) {
}
}
+62 -167
View File
@@ -22,9 +22,6 @@
namespace {
// Case-insensitive substring check. System font filenames differ in case
// across platforms (e.g. macOS ships "Times.ttc", Windows "times.ttf"), so the
// font-fallback assertions match without regard to case.
bool containsCI(const std::string& haystack, const std::string& needle) {
auto it = std::search(
haystack.begin(), haystack.end(), needle.begin(), needle.end(),
@@ -50,14 +47,12 @@ bool saveGlyphAsPGM(const pdfengine::fonts::GlyphBitmap& bitmap, const std::stri
std::string getSystemFontPath() {
#if defined(_WIN32)
// Common Windows fonts
std::vector<std::string> paths = {
"C:\\Windows\\Fonts\\arial.ttf",
"C:\\Windows\\Fonts\\consola.ttf",
"C:\\Windows\\Fonts\\tahoma.ttf"
};
#elif defined(__APPLE__)
// Common macOS fonts
std::vector<std::string> paths = {
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Geneva.ttf",
@@ -65,7 +60,6 @@ std::string getSystemFontPath() {
"/System/Library/Fonts/Supplemental/Arial.ttf"
};
#else
// Common Linux fonts
std::vector<std::string> paths = {
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
@@ -81,7 +75,7 @@ std::string getSystemFontPath() {
return "";
}
} // namespace
}
namespace pdfengine::fonts {
@@ -107,12 +101,10 @@ TEST(FontTest, FontFaceMoveSemantics) {
FT_Face rawFace = face1.getFace();
ASSERT_NE(rawFace, nullptr);
// Move construction
FontFace face2(std::move(face1));
EXPECT_EQ(face1.getFace(), nullptr);
EXPECT_EQ(face2.getFace(), rawFace);
// Move assignment
FontFace face3;
face3 = std::move(face2);
EXPECT_EQ(face2.getFace(), nullptr);
@@ -134,7 +126,7 @@ TEST(FontTest, HbShaperEmptyInput) {
}
TEST(FontTest, HbShaperNullFace) {
FontFace face; // Null face
FontFace face;
HbShaper shaper;
auto glyphs = shaper.shapeRun("Hello", face, 16);
EXPECT_TRUE(glyphs.empty());
@@ -155,20 +147,15 @@ TEST(FontTest, HbShaperShapeTextSuccess) {
std::string testText = "Hello World!";
auto glyphs = shaper.shapeRun(testText, face, 16);
// Validate that some glyphs were shaped.
// Note that the number of glyphs doesn't strictly have to match testText.length() (e.g. ligatures),
// but for simple English it's usually 1:1.
EXPECT_FALSE(glyphs.empty());
for (const auto& g : glyphs) {
// Glyph index should be non-zero for valid glyphs (0 is usually .notdef)
// Note: some fonts might not map all characters, but Arial/DejaVu/Consolas should map ASCII.
EXPECT_GT(g.advanceX, 0.0);
}
}
TEST(FontTest, FontFaceRenderGlyphNullFace) {
FontFace face; // Null face
FontFace face;
auto glyph = face.renderGlyph(0, 16);
EXPECT_FALSE(glyph.has_value());
}
@@ -183,11 +170,9 @@ TEST(FontTest, FontFaceRenderGlyphSuccess) {
ASSERT_TRUE(face.loadFromFile(fontPath));
ASSERT_NE(face.getFace(), nullptr);
// Get the glyph index for character 'A'.
unsigned int glyphIndex = FT_Get_Char_Index(face.getFace(), 'A');
ASSERT_GT(glyphIndex, 0u); // Ensure it's not the undefined glyph
ASSERT_GT(glyphIndex, 0u);
// Render it at 24px.
auto glyphOpt = face.renderGlyph(glyphIndex, 24);
ASSERT_TRUE(glyphOpt.has_value());
@@ -211,22 +196,18 @@ TEST(FontTest, GlyphCacheBasicGetInsert) {
EXPECT_EQ(cache.capacity(), 10u);
EXPECT_EQ(cache.size(), 0u);
// Initial check: cache miss
auto miss = cache.get(face, 12, 16);
EXPECT_FALSE(miss.has_value());
// Create a dummy GlyphBitmap
GlyphBitmap bitmap;
bitmap.width = 10;
bitmap.height = 12;
bitmap.pixels = std::vector<unsigned char>(120, 255);
bitmap.advance = 8.5;
// Insert
cache.insert(face, 12, 16, bitmap);
EXPECT_EQ(cache.size(), 1u);
// Cache hit
auto hit = cache.get(face, 12, 16);
ASSERT_TRUE(hit.has_value());
EXPECT_EQ(hit->width, 10);
@@ -244,7 +225,6 @@ TEST(FontTest, GlyphCacheEvictionPolicy) {
FontFace face;
ASSERT_TRUE(face.loadFromFile(fontPath));
// Capacity 2
GlyphCache cache(2);
GlyphBitmap bmp1{ .width = 1 };
@@ -255,7 +235,6 @@ TEST(FontTest, GlyphCacheEvictionPolicy) {
cache.insert(face, 2, 16, bmp2);
EXPECT_EQ(cache.size(), 2u);
// Insert third one: should evict the oldest (1, 16)
cache.insert(face, 3, 16, bmp3);
EXPECT_EQ(cache.size(), 2u);
@@ -282,11 +261,9 @@ TEST(FontTest, GlyphCacheLRUPolicy) {
cache.insert(face, 1, 16, bmp1);
cache.insert(face, 2, 16, bmp2);
// Access 1 to make it most recently used
auto hit = cache.get(face, 1, 16);
ASSERT_TRUE(hit.has_value());
// Insert 3: since 2 is the oldest (least recently used), 2 should be evicted and 1 should remain
cache.insert(face, 3, 16, bmp3);
EXPECT_EQ(cache.size(), 2u);
@@ -343,18 +320,15 @@ TEST(FontTest, UnicodeAndRtlShaping) {
HbShaper shaper;
unsigned int fontSize = 16;
// Test A: Arabic (RTL) - "سلام"
{
std::string arabicText = "سلام";
auto glyphs = shaper.shapeRun(arabicText, face, fontSize);
EXPECT_FALSE(glyphs.empty());
for (const auto& g : glyphs) {
// Validate that shaping executed and returned valid layout metrics
EXPECT_TRUE(g.advanceX != 0.0 || g.advanceY != 0.0 || g.offsetX != 0.0 || g.offsetY != 0.0 || g.glyphIndex != 999999u);
}
}
// Test B: Hindi - "नमस्ते"
{
std::string hindiText = "नमस्ते";
auto glyphs = shaper.shapeRun(hindiText, face, fontSize);
@@ -364,7 +338,6 @@ TEST(FontTest, UnicodeAndRtlShaping) {
}
}
// Test C: Ligatures - "office"
{
std::string ligatureText = "office";
auto glyphs = shaper.shapeRun(ligatureText, face, fontSize);
@@ -541,20 +514,14 @@ TEST(FontTest, CacheRecencyStress) {
ASSERT_TRUE(cache.get(face, 2, 16).has_value());
cache.insert(face, 5, 16, bmps[5]);
// 1 should be evicted because it was the oldest
EXPECT_FALSE(cache.get(face, 1, 16).has_value());
// 5 was just inserted, should be at the front
EXPECT_TRUE(cache.get(face, 5, 16).has_value());
// Access 3 to promote it to the front
ASSERT_TRUE(cache.get(face, 3, 16).has_value());
// Insert 6. With cache.get lookups, 4 is now the oldest (since 3, 5, 2, 0 have been looked up recently)
cache.insert(face, 6, 16, bmps[6]);
// 4 should be evicted
EXPECT_FALSE(cache.get(face, 4, 16).has_value());
// The rest should remain
EXPECT_TRUE(cache.get(face, 0, 16).has_value());
EXPECT_TRUE(cache.get(face, 2, 16).has_value());
EXPECT_TRUE(cache.get(face, 3, 16).has_value());
@@ -568,7 +535,6 @@ TEST(FontLoaderTest, FontFaceLoadFromMemorySuccess) {
GTEST_SKIP() << "No system font found to run load from memory success test.";
}
// Read the entire file into a buffer
std::ifstream file(fontPath, std::ios::binary | std::ios::ate);
ASSERT_TRUE(file.is_open());
std::streamsize size = file.tellg();
@@ -577,12 +543,10 @@ TEST(FontLoaderTest, FontFaceLoadFromMemorySuccess) {
std::vector<uint8_t> buffer(size);
ASSERT_TRUE(file.read(reinterpret_cast<char*>(buffer.data()), size));
// Load from memory
FontFace face;
ASSERT_TRUE(face.loadFromMemory(buffer));
ASSERT_NE(face.getFace(), nullptr);
// Validate that glyph rendering and metrics are valid
unsigned int glyphIndex = FT_Get_Char_Index(face.getFace(), 'M');
ASSERT_GT(glyphIndex, 0u);
@@ -595,12 +559,10 @@ TEST(FontLoaderTest, FontFaceLoadFromMemorySuccess) {
TEST(FontLoaderTest, FontFaceLoadFromMemoryInvalid) {
FontFace face;
// Empty vector
std::vector<uint8_t> emptyData;
EXPECT_FALSE(face.loadFromMemory(emptyData));
EXPECT_EQ(face.getFace(), nullptr);
// Corrupt garbage data
std::vector<uint8_t> corruptData = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
EXPECT_FALSE(face.loadFromMemory(corruptData));
EXPECT_EQ(face.getFace(), nullptr);
@@ -619,14 +581,12 @@ TEST(FontLoaderTest, FontLoaderTrueTypeSuccess) {
std::vector<uint8_t> buffer(size);
ASSERT_TRUE(file.read(reinterpret_cast<char*>(buffer.data()), size));
// Use factory loader
auto pdfFont = pdfengine::fonts::pdf_fonts::FontLoader::loadTrueTypeFromMemory("Arial", buffer);
ASSERT_NE(pdfFont, nullptr);
EXPECT_EQ(pdfFont->getBaseFont(), "Arial");
EXPECT_EQ(pdfFont->getType(), pdfengine::fonts::pdf_fonts::FontType::TrueType);
EXPECT_TRUE(pdfFont->isEmbedded());
// Shaping via the loaded FontFace
HbShaper shaper;
auto glyphs = shaper.shapeRun("Test Memory Load", pdfFont->getFontFace(), 16);
EXPECT_FALSE(glyphs.empty());
@@ -685,28 +645,22 @@ TEST(FontDescriptorTest, DescriptorParsingSuccess) {
EXPECT_DOUBLE_EQ(desc.getCapHeight(), 728.0);
EXPECT_DOUBLE_EQ(desc.getStemV(), 94.0);
// Check flags
EXPECT_FALSE(desc.isFixedPitch());
EXPECT_TRUE(desc.isNonsymbolic()); // 32
EXPECT_TRUE(desc.isNonsymbolic());
EXPECT_FALSE(desc.isItalic());
}
TEST(FontDescriptorTest, DescriptorParsingMalformed) {
pdfengine::fonts::pdf_fonts::FontDescriptor desc;
// Missing <<
EXPECT_FALSE(desc.parseFromDictionaryString("/Flags 32 >>"));
// Unmatched >>
EXPECT_FALSE(desc.parseFromDictionaryString("<< /Flags 32"));
// Malformed BBox array (missing urx, ury)
EXPECT_FALSE(desc.parseFromDictionaryString("<< /FontBBox [-166 -225] >>"));
// Malformed double conversion
EXPECT_FALSE(desc.parseFromDictionaryString("<< /Ascent abc >>"));
// Key without value
EXPECT_FALSE(desc.parseFromDictionaryString("<< /Ascent >>"));
}
@@ -723,14 +677,12 @@ TEST(FontDescriptorTest, FontLoaderWithDescriptor) {
std::vector<uint8_t> buffer(size);
ASSERT_TRUE(file.read(reinterpret_cast<char*>(buffer.data()), size));
// Create descriptor
auto descriptor = std::make_unique<pdfengine::fonts::pdf_fonts::FontDescriptor>();
descriptor->setFontName("Arial-BoldMT");
descriptor->setFlags(96); // Nonsymbolic (32) | Italic (64)
descriptor->setFlags(96);
descriptor->setAscent(905.0);
descriptor->setDescent(-211.0);
// Load with descriptor
auto pdfFont = pdfengine::fonts::pdf_fonts::FontLoader::loadTrueTypeFromMemory("Arial-Bold", buffer, std::move(descriptor));
ASSERT_NE(pdfFont, nullptr);
EXPECT_EQ(pdfFont->getBaseFont(), "Arial-Bold");
@@ -749,27 +701,24 @@ TEST(FontDescriptorTest, FontLoaderWithDescriptor) {
TEST(EncodingTest, PredefinedEncodingTest) {
using namespace pdfengine::fonts::pdf_fonts;
// WinAnsiEncoding
PredefinedEncoding winAnsi(SimpleEncodingType::WinAnsi);
EXPECT_EQ(winAnsi.getType(), SimpleEncodingType::WinAnsi);
EXPECT_EQ(winAnsi.decode(65), 65); // 'A'
EXPECT_EQ(winAnsi.decode(128), 0x20AC); // Euro symbol exception
EXPECT_EQ(winAnsi.decode(169), 169); // copyright symbol (standard ISO-8859-1)
EXPECT_EQ(winAnsi.decode(300), 0); // Out of bounds
EXPECT_EQ(winAnsi.decode(65), 65);
EXPECT_EQ(winAnsi.decode(128), 0x20AC);
EXPECT_EQ(winAnsi.decode(169), 169);
EXPECT_EQ(winAnsi.decode(300), 0);
// MacRomanEncoding
PredefinedEncoding macRoman(SimpleEncodingType::MacRoman);
EXPECT_EQ(macRoman.getType(), SimpleEncodingType::MacRoman);
EXPECT_EQ(macRoman.decode(65), 65); // 'A'
EXPECT_EQ(macRoman.decode(128), 0x00C4); // High page lookup exception (A-dieresis)
EXPECT_EQ(macRoman.decode(300), 0); // Out of bounds
EXPECT_EQ(macRoman.decode(65), 65);
EXPECT_EQ(macRoman.decode(128), 0x00C4);
EXPECT_EQ(macRoman.decode(300), 0);
// Identity encoding (pass-through)
PredefinedEncoding identity(SimpleEncodingType::Identity);
EXPECT_EQ(identity.getType(), SimpleEncodingType::Identity);
EXPECT_EQ(identity.decode(65), 65);
EXPECT_EQ(identity.decode(128), 128);
EXPECT_EQ(identity.decode(1000), 1000); // Beyond 255 pass-through
EXPECT_EQ(identity.decode(1000), 1000);
}
TEST(EncodingTest, CustomEncodingWithDifferences) {
@@ -778,24 +727,19 @@ TEST(EncodingTest, CustomEncodingWithDifferences) {
auto baseEncoding = std::make_unique<PredefinedEncoding>(SimpleEncodingType::WinAnsi);
CustomEncoding custom(std::move(baseEncoding));
// Fallback to base
EXPECT_EQ(custom.decode(65), 65);
// Standard glyph name difference mapping
custom.addDifference(120, "quotesingle");
EXPECT_EQ(custom.decode(120), 0x0027);
// Unicode-like glyph name (uniXXXX) difference mapping
custom.addDifference(121, "uni0041");
EXPECT_EQ(custom.decode(121), 0x0041); // 'A'
EXPECT_EQ(custom.decode(121), 0x0041);
// uXXXX representation
custom.addDifference(122, "u0042");
EXPECT_EQ(custom.decode(122), 0x0042); // 'B'
EXPECT_EQ(custom.decode(122), 0x0042);
// Non-existent or unresolved glyph name
custom.addDifference(123, "nonexistentglyphname123");
EXPECT_EQ(custom.decode(123), 123); // Falls back to base (WinAnsi maps 123 to 123 '{')
EXPECT_EQ(custom.decode(123), 123);
}
TEST(EncodingTest, ToUnicodeCMapbfchar) {
@@ -822,9 +766,9 @@ TEST(EncodingTest, ToUnicodeCMapbfchar) {
"end\n";
ASSERT_TRUE(cmap.parseCMapStream(cmapStream));
EXPECT_EQ(cmap.decode(1), 0x0041); // 'A'
EXPECT_EQ(cmap.decode(2), 0x0042); // 'B'
EXPECT_EQ(cmap.decode(3), 0); // Missing
EXPECT_EQ(cmap.decode(1), 0x0041);
EXPECT_EQ(cmap.decode(2), 0x0042);
EXPECT_EQ(cmap.decode(3), 0);
}
TEST(EncodingTest, ToUnicodeCMapbfrange) {
@@ -835,22 +779,20 @@ TEST(EncodingTest, ToUnicodeCMapbfrange) {
std::string cmapStream =
"begincmap\n"
"2 beginbfrange\n"
"<0001> <0005> <0041>\n" // Sequential base mapping (<0001> to <0005> starting at <0041>)
"<0010> <0012> [<0061> <0062> <0063>]\n" // Array mapping
"<0001> <0005> <0041>\n"
"<0010> <0012> [<0061> <0062> <0063>]\n"
"endbfrange\n"
"endcmap\n";
ASSERT_TRUE(cmap.parseCMapStream(cmapStream));
// Assert sequential
EXPECT_EQ(cmap.decode(1), 0x0041); // 'A'
EXPECT_EQ(cmap.decode(3), 0x0043); // 'C'
EXPECT_EQ(cmap.decode(5), 0x0045); // 'E'
EXPECT_EQ(cmap.decode(1), 0x0041);
EXPECT_EQ(cmap.decode(3), 0x0043);
EXPECT_EQ(cmap.decode(5), 0x0045);
// Assert array
EXPECT_EQ(cmap.decode(0x10), 0x0061); // 'a'
EXPECT_EQ(cmap.decode(0x11), 0x0062); // 'b'
EXPECT_EQ(cmap.decode(0x12), 0x0063); // 'c'
EXPECT_EQ(cmap.decode(0x10), 0x0061);
EXPECT_EQ(cmap.decode(0x11), 0x0062);
EXPECT_EQ(cmap.decode(0x12), 0x0063);
}
TEST(EncodingTest, ToUnicodeMalformedCMap) {
@@ -858,17 +800,15 @@ TEST(EncodingTest, ToUnicodeMalformedCMap) {
ToUnicodeCMap cmap;
// Completely invalid/garbage content
std::string garbageStream = "This is a garbage string with no valid CMap elements";
EXPECT_FALSE(cmap.parseCMapStream(garbageStream));
// Partially valid CMap - should recover parsed entries
std::string partialStream =
"begincmap\n"
"beginbfchar\n"
"<0001> <0041>\n" // Valid
"<0002> /invalid\n" // Invalid/missing dest (non-hex)
"<0003> <0043>\n" // Valid
"<0001> <0041>\n"
"<0002> /invalid\n"
"<0003> <0043>\n"
"endbfchar\n"
"endcmap\n";
@@ -891,12 +831,10 @@ TEST(EncodingTest, FontLoaderWithEncoding) {
std::vector<uint8_t> buffer(size);
ASSERT_TRUE(file.read(reinterpret_cast<char*>(buffer.data()), size));
// Create predefined encoding (WinAnsi)
auto encoding = std::make_unique<pdfengine::fonts::pdf_fonts::PredefinedEncoding>(
pdfengine::fonts::pdf_fonts::SimpleEncodingType::WinAnsi
);
// Load font with encoding
auto pdfFont = pdfengine::fonts::pdf_fonts::FontLoader::loadTrueTypeFromMemory(
"Arial-With-Encoding", buffer, nullptr, std::move(encoding)
);
@@ -907,7 +845,6 @@ TEST(EncodingTest, FontLoaderWithEncoding) {
const auto* retrievedEncoding = pdfFont->getEncoding();
ASSERT_NE(retrievedEncoding, nullptr);
// Verify it translates Euro symbol exception properly
EXPECT_EQ(retrievedEncoding->decode(128), 0x20AC);
}
@@ -960,14 +897,12 @@ TEST(CIDFontTest, CompositeFontInitializationAndTypes) {
auto desc = std::make_unique<FontDescriptor>();
desc->setFontName("SimSun-Descriptor");
// Test CIDFontType0
CIDFont font0("SimSun", FontType::CIDFontType0, false, std::move(desc));
EXPECT_EQ(font0.getBaseFont(), "SimSun");
EXPECT_EQ(font0.getType(), FontType::CIDFontType0);
EXPECT_FALSE(font0.isEmbedded());
EXPECT_EQ(font0.getDescriptor()->getFontName(), "SimSun-Descriptor");
// Test CIDFontType2
CIDFont font2("MS-Gothic", FontType::CIDFontType2, true);
EXPECT_EQ(font2.getBaseFont(), "MS-Gothic");
EXPECT_EQ(font2.getType(), FontType::CIDFontType2);
@@ -979,12 +914,10 @@ TEST(CIDFontTest, CIDToGIDTranslations) {
CIDFont font("SimSun", FontType::CIDFontType2, false);
// By default, it should be an identity mapping
EXPECT_TRUE(font.isIdentityMap());
EXPECT_EQ(font.mapCIDToGID(100), 100);
EXPECT_EQ(font.mapCIDToGID(5000), 5000);
// Set custom mapping
std::unordered_map<uint32_t, uint32_t> customMap = {
{10, 100},
{20, 200},
@@ -995,9 +928,8 @@ TEST(CIDFontTest, CIDToGIDTranslations) {
EXPECT_EQ(font.mapCIDToGID(10), 100);
EXPECT_EQ(font.mapCIDToGID(20), 200);
EXPECT_EQ(font.mapCIDToGID(30), 300);
EXPECT_EQ(font.mapCIDToGID(40), 0); // Undefined / missing
EXPECT_EQ(font.mapCIDToGID(40), 0);
// Set back to identity
font.setIdentityCIDToGIDMap();
EXPECT_TRUE(font.isIdentityMap());
EXPECT_EQ(font.mapCIDToGID(10), 10);
@@ -1006,22 +938,18 @@ TEST(CIDFontTest, CIDToGIDTranslations) {
TEST(CIDFontTest, NonEmbeddedCIDFontSystemFallback) {
using namespace pdfengine::fonts::pdf_fonts;
// Load Chinese Simplified CJK fallback
auto fontSimSun = FontLoader::loadCIDFontSystemFallback("SimSun", FontType::CIDFontType2);
ASSERT_NE(fontSimSun, nullptr);
EXPECT_EQ(fontSimSun->getBaseFont(), "SimSun");
EXPECT_EQ(fontSimSun->getType(), FontType::CIDFontType2);
EXPECT_FALSE(fontSimSun->isEmbedded());
// Load Japanese CJK fallback
auto fontGothic = FontLoader::loadCIDFontSystemFallback("HeiseiMin-W3", FontType::CIDFontType0);
ASSERT_NE(fontGothic, nullptr);
EXPECT_EQ(fontGothic->getBaseFont(), "HeiseiMin-W3");
EXPECT_EQ(fontGothic->getType(), FontType::CIDFontType0);
EXPECT_FALSE(fontGothic->isEmbedded());
// Verify it resolved to a valid system font that can render and shape text
// E.g. we can shape a basic CJK run with SimSun or MS Gothic (like Japanese characters)
HbShaper shaper;
auto glyphs = shaper.shapeRun("日本語漢字", fontGothic->getFontFace(), 16);
EXPECT_FALSE(glyphs.empty());
@@ -1040,30 +968,30 @@ TEST(FontFallbackTest, StandardFontFallbacks) {
auto& fallback = FontFallback::getInstance();
// Helvetica resolves to its sans-serif substitute for the host platform.
std::string path1 = fallback.getFallbackFontPath("Helvetica");
EXPECT_FALSE(path1.empty());
EXPECT_TRUE(std::filesystem::exists(path1));
#if defined(_WIN32)
EXPECT_TRUE(containsCI(path1, "arial") || containsCI(path1, "liberationsans"));
#elif defined(__APPLE__)
// Arial may not be installed; the resolver then falls back to Helvetica.
EXPECT_TRUE(containsCI(path1, "arial") || containsCI(path1, "helvetica") ||
containsCI(path1, "liberationsans"));
#else
EXPECT_TRUE(containsCI(path1, "liberationsans") || containsCI(path1, "dejavusans"));
#endif
// Times resolves to its serif substitute for the host platform.
std::string path2 = fallback.getFallbackFontPath("Times-Roman");
EXPECT_FALSE(path2.empty());
EXPECT_TRUE(std::filesystem::exists(path2));
#if defined(_WIN32)
EXPECT_TRUE(containsCI(path2, "times") || containsCI(path2, "liberationserif"));
EXPECT_TRUE(containsCI(path2, "times") || containsCI(path2, "liberationserif") ||
containsCI(path2, "tinos"));
#elif defined(__APPLE__)
EXPECT_TRUE(containsCI(path2, "times") || containsCI(path2, "liberationserif"));
EXPECT_TRUE(containsCI(path2, "times") || containsCI(path2, "liberationserif") ||
containsCI(path2, "tinos"));
#else
EXPECT_TRUE(containsCI(path2, "liberationserif") || containsCI(path2, "dejavuserif"));
EXPECT_TRUE(containsCI(path2, "liberationserif") || containsCI(path2, "dejavuserif") ||
containsCI(path2, "tinos"));
#endif
}
@@ -1072,22 +1000,20 @@ TEST(FontFallbackTest, StyleModifierResolutions) {
auto& fallback = FontFallback::getInstance();
// Bold Helvetica should map to a bold sans-serif substitute.
std::string pathBold = fallback.getFallbackFontPath("Helvetica", true, false);
EXPECT_FALSE(pathBold.empty());
EXPECT_TRUE(std::filesystem::exists(pathBold));
#if defined(_WIN32)
// Windows ships the styled variants, so assert the exact bold face.
EXPECT_TRUE(containsCI(pathBold, "arialbd") || containsCI(pathBold, "liberationsans-bold"));
#endif
// Bold-italic Times should map to a bold-italic serif substitute.
std::string pathBoldItalic = fallback.getFallbackFontPath("Times", true, true);
EXPECT_FALSE(pathBoldItalic.empty());
EXPECT_TRUE(std::filesystem::exists(pathBoldItalic));
#if defined(_WIN32)
EXPECT_TRUE(containsCI(pathBoldItalic, "timesbi") ||
containsCI(pathBoldItalic, "liberationserif-bolditalic"));
containsCI(pathBoldItalic, "liberationserif-bolditalic") ||
containsCI(pathBoldItalic, "tinos-bolditalic"));
#endif
}
@@ -1097,11 +1023,8 @@ TEST(FontFallbackTest, CustomFallbackRegistration) {
auto& fallback = FontFallback::getInstance();
fallback.resetToDefaults();
// Lookup the default substitute path for Helvetica.
std::string standardPath = fallback.getFallbackFontPath("Helvetica");
// The resolver only returns an override whose file actually exists on disk,
// so register a real temp file rather than a hardcoded OS-specific path.
std::filesystem::path overrideFont =
std::filesystem::temp_directory_path() / "pdfengine_custom_fallback.ttf";
{ std::ofstream(overrideFont) << "stub-font"; }
@@ -1111,7 +1034,6 @@ TEST(FontFallbackTest, CustomFallbackRegistration) {
std::string overridenPath = fallback.getFallbackFontPath("Helvetica");
EXPECT_EQ(overridenPath, overrideFont.string());
// Reset back to defaults and confirm the original substitute returns.
fallback.resetToDefaults();
std::string restoredPath = fallback.getFallbackFontPath("Helvetica");
EXPECT_EQ(restoredPath, standardPath);
@@ -1128,7 +1050,6 @@ TEST(FontSubsetTest, SubsetTagParsingAndStripping) {
EXPECT_EQ(FontSubset::getSubsetPrefix(subsetName), "KTJHQO");
EXPECT_EQ(FontSubset::stripSubsetPrefix(subsetName), "Arial");
// Standard naming (no prefix)
std::string normalName = "Arial";
EXPECT_FALSE(FontSubset::hasSubsetPrefix(normalName));
EXPECT_EQ(FontSubset::getSubsetPrefix(normalName), "");
@@ -1138,20 +1059,16 @@ TEST(FontSubsetTest, SubsetTagParsingAndStripping) {
TEST(FontSubsetTest, PrefixFormatValidation) {
using namespace pdfengine::fonts::pdf_fonts;
// Prefixes must be exactly 6 UPPERCASE letters followed by '+'
EXPECT_TRUE(FontSubset::hasSubsetPrefix("ABCDEF+Helvetica"));
// Lowercase should fail
EXPECT_FALSE(FontSubset::hasSubsetPrefix("abcDEF+Helvetica"));
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABCdef+Helvetica"));
// Numbers/Special should fail
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABC123+Helvetica"));
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABCDE_+Helvetica"));
// Length must be exactly 6 characters
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABCDE+Helvetica")); // 5 chars
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABCDEFG+Helvetica")); // 7 chars
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABCDE+Helvetica"));
EXPECT_FALSE(FontSubset::hasSubsetPrefix("ABCDEFG+Helvetica"));
}
TEST(FontSubsetTest, GIDRemappingTranslations) {
@@ -1163,14 +1080,12 @@ TEST(FontSubsetTest, GIDRemappingTranslations) {
EXPECT_EQ(subset.getBaseFontName(), "Arial");
EXPECT_EQ(subset.getPrefix(), "KTJHQO");
// Default pass-through lookup
EXPECT_EQ(subset.mapSubsetToOriginal(5), 5);
EXPECT_FALSE(subset.hasGlyphMapping(5));
// Register glyph ID translations
subset.addGlyphMapping(1, 41); // 'A'
subset.addGlyphMapping(2, 42); // 'B'
subset.addGlyphMapping(3, 43); // 'C'
subset.addGlyphMapping(1, 41);
subset.addGlyphMapping(2, 42);
subset.addGlyphMapping(3, 43);
EXPECT_EQ(subset.getMappingCount(), 3u);
EXPECT_TRUE(subset.hasGlyphMapping(1));
@@ -1178,13 +1093,12 @@ TEST(FontSubsetTest, GIDRemappingTranslations) {
EXPECT_EQ(subset.mapSubsetToOriginal(1), 41);
EXPECT_EQ(subset.mapSubsetToOriginal(2), 42);
EXPECT_EQ(subset.mapSubsetToOriginal(3), 43);
EXPECT_EQ(subset.mapSubsetToOriginal(4), 4); // Falls back to standard GID
EXPECT_EQ(subset.mapSubsetToOriginal(4), 4);
}
TEST(FontSubsetTest, CoreFontSubsettingIntegration) {
using namespace pdfengine::fonts::pdf_fonts;
// Test TrueType Font integration
TrueTypeFont fontTT("KTJHQO+Arial", false);
const auto* ttSubset = fontTT.getSubsetInfo();
ASSERT_NE(ttSubset, nullptr);
@@ -1192,7 +1106,6 @@ TEST(FontSubsetTest, CoreFontSubsettingIntegration) {
EXPECT_EQ(ttSubset->getBaseFontName(), "Arial");
EXPECT_EQ(ttSubset->getPrefix(), "KTJHQO");
// Test Type1 Font integration
Type1Font fontT1("SUBSET+Courier", false);
const auto* t1Subset = fontT1.getSubsetInfo();
ASSERT_NE(t1Subset, nullptr);
@@ -1200,7 +1113,6 @@ TEST(FontSubsetTest, CoreFontSubsettingIntegration) {
EXPECT_EQ(t1Subset->getBaseFontName(), "Courier");
EXPECT_EQ(t1Subset->getPrefix(), "SUBSET");
// Test CID Font integration
CIDFont fontCID("CJKTAG+SimSun", FontType::CIDFontType2, false);
const auto* cidSubset = fontCID.getSubsetInfo();
ASSERT_NE(cidSubset, nullptr);
@@ -1330,36 +1242,28 @@ TEST(TextExtractionLayerTest, StringUtf8Conversion) {
TEST(FontSubstitutionAndWidthsTest, WidthMatchingAndSubstitutionVerification) {
using namespace pdfengine::fonts::pdf_fonts;
// Load non-embedded Helvetica font, which triggers substitution
auto font = FontLoader::loadType1SystemFallback("Helvetica");
ASSERT_NE(font, nullptr);
// Confirm that the font does not have widths set yet
EXPECT_FALSE(font->hasWidths());
// Original widths for character codes 65 to 68 ('A' to 'D') from PDF /Widths array
// E.g., 'A'=600, 'B'=500, 'C'=550, 'D'=400
std::vector<double> pdfWidths = { 600.0, 500.0, 550.0, 400.0 };
font->setWidths(65, 68, pdfWidths);
EXPECT_TRUE(font->hasWidths());
// Font size context: 12.0
double fontSize = 12.0;
// Expected widths = (W / 1000.0) * fontSize
double expectedWidthA = (600.0 / 1000.0) * fontSize; // 7.2
double expectedWidthB = (500.0 / 1000.0) * fontSize; // 6.0
double expectedWidthC = (550.0 / 1000.0) * fontSize; // 6.6
double expectedWidthD = (400.0 / 1000.0) * fontSize; // 4.8
double expectedWidthA = (600.0 / 1000.0) * fontSize;
double expectedWidthB = (500.0 / 1000.0) * fontSize;
double expectedWidthC = (550.0 / 1000.0) * fontSize;
double expectedWidthD = (400.0 / 1000.0) * fontSize;
// Verify mapped widths match original PDF widths with 0% error (well under 5%)
EXPECT_NEAR(font->getCharWidth(65, fontSize), expectedWidthA, 1e-5);
EXPECT_NEAR(font->getCharWidth(66, fontSize), expectedWidthB, 1e-5);
EXPECT_NEAR(font->getCharWidth(67, fontSize), expectedWidthC, 1e-5);
EXPECT_NEAR(font->getCharWidth(68, fontSize), expectedWidthD, 1e-5);
// Verify out-of-range character falls back safely (returns 0.0 or descriptor missing width)
EXPECT_EQ(font->getCharWidth(999, fontSize), 0.0);
}
@@ -1377,12 +1281,10 @@ TEST(CIDAdvancedMappingTest, VerticalMetricsResolution) {
auto font = FontLoader::loadType1SystemFallback("Helvetica");
ASSERT_NE(font, nullptr);
// Default vertical advance metrics: 1.0em = font size context
double fontSize = 12.0;
EXPECT_EQ(font->isVertical(), false);
EXPECT_EQ(font->getCharHeight(65, fontSize), fontSize);
// Turn vertical metrics ON and verify custom heights
font->setVertical(true);
EXPECT_EQ(font->isVertical(), true);
@@ -1390,39 +1292,32 @@ TEST(CIDAdvancedMappingTest, VerticalMetricsResolution) {
font->setVerticalMetrics(65, 67, verticalAdvances);
EXPECT_TRUE(font->hasVerticalMetrics());
// Expected heights: (Adv / 1000.0) * fontSize
EXPECT_NEAR(font->getCharHeight(65, fontSize), 12.0, 1e-5); // (1000/1000) * 12
EXPECT_NEAR(font->getCharHeight(66, fontSize), 9.6, 1e-5); // (800/1000) * 12
EXPECT_NEAR(font->getCharHeight(67, fontSize), 10.8, 1e-5); // (900/1000) * 12
EXPECT_NEAR(font->getCharHeight(999, fontSize), 12.0, 1e-5); // Fallback to 12.0
EXPECT_NEAR(font->getCharHeight(65, fontSize), 12.0, 1e-5);
EXPECT_NEAR(font->getCharHeight(66, fontSize), 9.6, 1e-5);
EXPECT_NEAR(font->getCharHeight(67, fontSize), 10.8, 1e-5);
EXPECT_NEAR(font->getCharHeight(999, fontSize), 12.0, 1e-5);
}
TEST(CIDAdvancedMappingTest, CjkCollectionResolutionDB) {
using namespace pdfengine::fonts::pdf_fonts;
// Standard Adobe-Japan1 Hiragana CIDs
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1010), 0x3041); // Hiragana 'ぁ'
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1092), 0x3093); // Hiragana 'ん'
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1010), 0x3041);
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1092), 0x3093);
// Standard Adobe-Japan1 Katakana CIDs
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1125), 0x30A1); // Katakana 'ァ'
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1205), 0x30F6); // Katakana 'ヶ'
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1125), 0x30A1);
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1205), 0x30F6);
// Core Kanji
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1206), 0x4E00); // Kanji '一'
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-Japan1", 1206), 0x4E00);
// GB1 Chinese simplified ideographic marks
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-GB1", 1), 0x3000); // space
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-GB1", 2), 0x3001); // comma
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-GB1", 1), 0x3000);
EXPECT_EQ(CjkCollectionDB::resolveCID("Adobe-GB1", 2), 0x3001);
}
TEST(CIDAdvancedMappingTest, HarfBuzzVerticalShapingSignature) {
using namespace pdfengine::fonts;
// Validate WritingMode configurations
EXPECT_EQ(static_cast<int>(HbShaper::WritingMode::Horizontal), 0);
EXPECT_EQ(static_cast<int>(HbShaper::WritingMode::Vertical), 1);
}
} // namespace pdfengine::fonts
}
+3 -8
View File
@@ -32,33 +32,28 @@ TEST(MatrixTest, TransformPoint) {
float y = 5.0f;
m.transform(x, y);
EXPECT_FLOAT_EQ(x, 20.0f); // 2*5 + 10
EXPECT_FLOAT_EQ(y, 35.0f); // 3*5 + 20
EXPECT_FLOAT_EQ(x, 20.0f);
EXPECT_FLOAT_EQ(y, 35.0f);
}
TEST(GraphicsStateStackTest, PushPop) {
GraphicsStateStack stack;
// Initial state
stack.current().lineWidth = 5.0f;
// Push new state
stack.push();
EXPECT_FLOAT_EQ(stack.current().lineWidth, 5.0f);
// Modify current state
stack.current().lineWidth = 10.0f;
EXPECT_FLOAT_EQ(stack.current().lineWidth, 10.0f);
// Pop back to initial
stack.pop();
EXPECT_FLOAT_EQ(stack.current().lineWidth, 5.0f);
}
TEST(GraphicsStateStackTest, PopEmptyProtection) {
GraphicsStateStack stack;
// Attempting to pop the root state should be safe (ignored)
stack.pop();
stack.current().lineWidth = 2.0f; // Should still be valid
stack.current().lineWidth = 2.0f;
EXPECT_FLOAT_EQ(stack.current().lineWidth, 2.0f);
}
+1 -1
View File
@@ -406,7 +406,7 @@ std::vector<uint8_t> rgb(std::initializer_list<uint8_t> values) {
return std::vector<uint8_t>(values);
}
} // namespace
}
TEST(ImageXObjectVerification, JpegLogoAndPhotoDecodeAndMatchPdfiumRender) {
const auto logoJpeg = encodeJpegRgb(rgb({
+1 -7
View File
@@ -40,7 +40,7 @@ TEST(LexerTest, Strings) {
EXPECT_EQ(tokens[2].stringValue, "Escapes \n \t \\ ()");
EXPECT_EQ(tokens[3].type, TokenType::String);
EXPECT_EQ(tokens[3].stringValue, "Octal +"); // \053 is '+'
EXPECT_EQ(tokens[3].stringValue, "Octal +");
}
TEST(LexerTest, HexStrings) {
@@ -49,12 +49,10 @@ TEST(LexerTest, HexStrings) {
ASSERT_EQ(tokens.size(), 2);
EXPECT_EQ(tokens[0].type, TokenType::HexString);
// "Hello"
std::vector<uint8_t> expected1 = {0x48, 0x65, 0x6C, 0x6C, 0x6F};
EXPECT_EQ(tokens[0].bytesValue, expected1);
EXPECT_EQ(tokens[1].type, TokenType::HexString);
// "4A5" padded to "4A50"
std::vector<uint8_t> expected2 = {0x4A, 0x50};
EXPECT_EQ(tokens[1].bytesValue, expected2);
}
@@ -113,15 +111,11 @@ TEST(LexerTest, IntegrationHelloWorld) {
Lexer lexer(stream->decodedContent);
auto tokens = lexer.tokenize();
// We expect something like: BT /F1 12 Tf (Hello) Tj ET
// plus any graphics state like 0 0 0 rg, etc.
// Let's just find the text block.
bool foundHello = false;
for (size_t i = 0; i < tokens.size(); ++i) {
if (tokens[i].type == TokenType::String && tokens[i].stringValue == "Hello, world!") {
foundHello = true;
// The next token should be Tj or TJ
ASSERT_LT(i + 1, tokens.size());
EXPECT_EQ(tokens[i+1].type, TokenType::Operator);
EXPECT_TRUE(tokens[i+1].stringValue == "Tj" || tokens[i+1].stringValue == "TJ");
-6
View File
@@ -37,27 +37,21 @@ TEST(ParserTest, ArraysAndDicts) {
ContentParser parser(tokens);
auto ops = parser.parse();
// The dictionary and array and string are ALL pushed onto the operand stack
// until the operator 'Tj' is encountered.
// Tj will consume all of them.
ASSERT_EQ(ops.size(), 1);
EXPECT_EQ(ops[0].op, "Tj");
ASSERT_EQ(ops[0].operands.size(), 3);
// First operand: Dict
auto dictNode = ops[0].operands[0];
EXPECT_EQ(dictNode->type, AstNodeType::Dictionary);
ASSERT_TRUE(dictNode->dictItems.find("Type") != dictNode->dictItems.end());
EXPECT_EQ(dictNode->dictItems["Type"]->stringValue, "Page");
// Second operand: Array
auto arrayNode = ops[0].operands[1];
EXPECT_EQ(arrayNode->type, AstNodeType::Array);
ASSERT_EQ(arrayNode->arrayItems.size(), 2);
EXPECT_DOUBLE_EQ(arrayNode->arrayItems[0]->numberValue, 1.0);
EXPECT_DOUBLE_EQ(arrayNode->arrayItems[1]->numberValue, 2.0);
// Third operand: String
auto strNode = ops[0].operands[2];
EXPECT_EQ(strNode->type, AstNodeType::String);
EXPECT_EQ(strNode->stringValue, "Text");
+1 -3
View File
@@ -39,7 +39,7 @@ TEST_F(QpdfExtractorTest, ExtractFromMemory) {
auto stream = extractor.extractPageStream(path.string(), 0);
ASSERT_TRUE(stream.has_value());
EXPECT_EQ(stream->pageIndex, 0);
EXPECT_FALSE(stream->compressed); // Or true depending on qpdf, but we only verify success here
EXPECT_FALSE(stream->compressed);
StreamVerification v = verifyContentStream(stream.value());
EXPECT_TRUE(v.hasBT);
@@ -70,7 +70,6 @@ TEST_F(QpdfExtractorTest, CorruptPdf) {
}
TEST_F(QpdfExtractorTest, EmptyContents) {
// about_blank.pdf usually has an empty page or no text
auto path = getCorpusPath("basic", "about_blank.pdf");
auto stream = extractor.extractPageStream(path.string(), 0);
ASSERT_TRUE(stream.has_value());
@@ -81,7 +80,6 @@ TEST_F(QpdfExtractorTest, EmptyContents) {
}
TEST_F(QpdfExtractorTest, VerifyNoText) {
// black.pdf or rectangles.pdf has no text, just graphics
auto path = getCorpusPath("basic", "black.pdf");
auto stream = extractor.extractPageStream(path.string(), 0);
ASSERT_TRUE(stream.has_value());
-8
View File
@@ -20,12 +20,10 @@ TEST(QpdfWriterTest, IntegrationReadModifyWrite) {
std::filesystem::path sourcePath = std::filesystem::path(TEST_CORPUS_DIR) / "basic" / "hello_world.pdf";
std::filesystem::path destPath = std::filesystem::path(TEST_CORPUS_DIR) / "basic" / "hello_world_modified.pdf";
// 1. Extract
QpdfExtractor extractor;
auto stream = extractor.extractPageStream(sourcePath.string(), 0);
ASSERT_TRUE(stream.has_value());
// 2. Lex, Parse, Build
Lexer lexer(stream->decodedContent);
auto tokens = lexer.tokenize();
ContentParser parser(tokens);
@@ -33,7 +31,6 @@ TEST(QpdfWriterTest, IntegrationReadModifyWrite) {
ContentBuilder builder;
auto objects = builder.build(ops);
// 3. Modify Text
bool foundAndModified = false;
for (auto& obj : objects) {
if (obj->getType() == ContentObjectType::Text) {
@@ -47,20 +44,16 @@ TEST(QpdfWriterTest, IntegrationReadModifyWrite) {
}
ASSERT_TRUE(foundAndModified) << "Could not find 'Hello, world!' to modify";
// 4. Serialize back to Ops
ContentSerializer contentSerializer;
auto newOps = contentSerializer.serialize(objects);
// 5. Serialize to raw bytes
AstSerializer astSerializer;
std::string newRawStream = astSerializer.serialize(newOps);
// 6. Write and Save PDF
QpdfWriter writer;
auto writeRes = writer.replacePageStreamAndSave(sourcePath.string(), destPath.string(), 0, newRawStream);
ASSERT_TRUE(writeRes.has_value()) << writeRes.error();
// 7. Re-open and verify modification
auto verifyStream = extractor.extractPageStream(destPath.string(), 0);
ASSERT_TRUE(verifyStream.has_value());
@@ -83,6 +76,5 @@ TEST(QpdfWriterTest, IntegrationReadModifyWrite) {
EXPECT_TRUE(verifiedModification) << "Modified string was not successfully saved and reloaded!";
// Cleanup
std::filesystem::remove(destPath);
}
+1 -4
View File
@@ -17,21 +17,18 @@ TEST(SkiaRendererTest, RenderSimpleDisplayList) {
canvas.clear(SK_ColorWHITE);
DisplayList dl;
// Push state and set fill color to red
dl.saveState();
dl.fillRect(10, 10, 50, 50); // Will be filled with red (we need a SetFillColorCommand, but right now GraphicsState doesn't expose it directly via Command yet. It will use default black)
dl.fillRect(10, 10, 50, 50);
dl.restoreState();
SkiaRenderer renderer(&canvas);
renderer.render(dl);
// Let's verify that a pixel at (20,20) was drawn. Since default color is black (0,0,0), we check that.
SkColor c = bitmap.getColor(20, 20);
EXPECT_EQ(SkColorGetR(c), 0);
EXPECT_EQ(SkColorGetG(c), 0);
EXPECT_EQ(SkColorGetB(c), 0);
// Check outside the rect
SkColor bg = bitmap.getColor(5, 5);
EXPECT_EQ(SkColorGetR(bg), 255);
EXPECT_EQ(SkColorGetG(bg), 255);
-4
View File
@@ -1,6 +1,3 @@
// Phase 0 smoke test: proves the engine library compiles, links against its
// vcpkg dependencies, and is callable. This is what Gate G0 ("infrastructure
// compiles on all platforms") checks in CI.
#include <gtest/gtest.h>
#include <pdfengine/pdf_engine.hpp>
#include <string_view>
@@ -28,6 +25,5 @@ TEST(EngineSmoke, BuildInfoConsistentWithSkiaLinkage) {
}
TEST(EngineSmoke, LogBuildInfoDoesNotThrow) {
// Exercises the spdlog dependency end to end (compile + link + call).
EXPECT_NO_THROW(pdfengine::engineLogBuildInfo());
}
-2
View File
@@ -3,13 +3,11 @@
extern "C" {
// Exported function: add two numbers
EMSCRIPTEN_KEEPALIVE
int add(int a, int b) {
return a + b;
}
// Exported function: print hello message
EMSCRIPTEN_KEEPALIVE
void hello() {
std::cout << "Hello from C++ WASM!" << std::endl;
+1 -36
View File
@@ -30,20 +30,16 @@ function App() {
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
const [activeDoc, setActiveDoc] = useState<DocumentInfo | null>(null);
// Document history powers undo/redo: each edit produces a new document id.
const [hist, setHist] = useState<{ stack: string[]; index: number }>({ stack: [], index: -1 });
const selectedDocId = hist.index >= 0 ? hist.stack[hist.index] : '';
const canUndo = hist.index > 0;
const canRedo = hist.index < hist.stack.length - 1;
const preservePageRef = useRef(false);
// Effective PDF permissions for the active document (carried forward across edits).
// `can` defaults to allowed when permissions are unknown / unencrypted.
const permissions = activeDoc?.permissions ?? null;
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
const denyToast = (label: string) =>
toast(`${label} is not permitted by this document's restrictions`, 'error');
// Tools the active document's permissions forbid (greyed out in the rail).
const disabledTools = new Set<ToolId>();
if (!can('canAnnotate'))
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
@@ -51,14 +47,12 @@ function App() {
const disabledToolsRef = useRef(disabledTools);
disabledToolsRef.current = disabledTools;
// View / tools
const [zoom, setZoom] = useState(1.0);
const [activeTool, setActiveTool] = useState<ToolId>('select');
const [toolSettings, setToolSettings] = useState<ToolSettings>(DEFAULT_TOOL_SETTINGS);
const [currentPage, setCurrentPage] = useState(0);
const [isInspectorOpen, setIsInspectorOpen] = useState(true);
// Data
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
const [fonts, setFonts] = useState<FontInfo[]>([]);
@@ -69,7 +63,6 @@ function App() {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// Tool aux state
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
const [aboutModalOpen, setAboutModalOpen] = useState(false);
@@ -77,14 +70,12 @@ function App() {
const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null);
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
// Search
const [searchQuery, setSearchQuery] = useState('');
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
const [searchWholeWords, setSearchWholeWords] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
/* ----------------------------------------------------- history helpers */
const openDocument = useCallback((id: string) => setHist({ stack: [id], index: 0 }), []);
const pushHistory = (id: string) =>
setHist((h) => ({ stack: [...h.stack.slice(0, h.index + 1), id], index: h.index + 1 }));
@@ -101,10 +92,7 @@ function App() {
toast('Redo', 'info', 1200);
};
/* ------------------------------------------------------ initial loads */
useEffect(() => {
// Healthy = the gateway answered. Engine availability is a separate concern
// (gateway can be up while the C++ engine bindings aren't built yet).
gatewayService.getHealth()
.then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); })
.catch(() => setBackendHealthy(false));
@@ -125,7 +113,6 @@ function App() {
})();
}, [openDocument]);
// Load metadata, annotations, fonts when the active document version changes.
useEffect(() => {
if (!selectedDocId) return;
let active = true;
@@ -153,7 +140,6 @@ function App() {
content: a.content,
timestamp: a.timestamp,
pageIndex: a.pageIndex,
// Ink stroke geometry (top-left page points) so the overlay can redraw it interactively.
paths: Array.isArray(a.paths) && a.paths.length > 0 ? a.paths : undefined,
fieldName: a.fieldName,
fieldValue: a.fieldValue,
@@ -172,7 +158,6 @@ function App() {
return () => { active = false; };
}, [selectedDocId]);
/* ---------------------------------------------------------- live search */
useEffect(() => {
const t = setTimeout(async () => {
if (!searchQuery || !selectedDocId) {
@@ -198,7 +183,6 @@ function App() {
viewerRef.current?.scrollToPage(searchResults[i].pageIndex);
};
/* ------------------------------------------------- keyboard shortcuts */
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
@@ -223,9 +207,6 @@ function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canUndo, canRedo, pendingSignature]);
/* -------------------------------------------------------- edit pipeline */
// Adopt a new document version produced by an edit (reflow, annotations, AND Raw Text via P2a):
// keep the current page visible, push it onto the undo/redo history, and refresh the doc list.
const adoptNewDocument = (newDocumentId: string) => {
preservePageRef.current = true;
pushHistory(newDocumentId);
@@ -251,7 +232,6 @@ function App() {
const pageHeightPts = (pageIndex: number) => activeDoc?.pages?.[pageIndex]?.height ?? activeDoc?.pageHeight ?? 792;
/* -------------------------------------- annotation creation (optimistic) */
const handleAnnotationAdded = (a: Annotation) => {
if (!can('canAnnotate')) { denyToast('Annotations'); return; }
setAnnotations((prev) => [...prev, a]);
@@ -285,17 +265,13 @@ function App() {
}
};
/* ---------------------------------------------- new overlay placements */
const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => {
if (!can('canAnnotate')) { denyToast('Text decorations'); return; }
// One markup annotation covering all selected lines. quadPoints are in top-left
// points (line px ÷ zoom); the engine flips to bottom-up, mirroring highlight.
const quadPoints = lines.map((line) => {
const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom;
return { x1: lx, y1: ly + lh, x2: lx + lw, y2: ly + lh, x3: lx + lw, y3: ly, x4: lx, y4: ly };
});
// Optimistically show local overlays until the edit round-trips.
const newAnnos = lines.map(line => ({
id: rid('locdec'),
type,
@@ -319,8 +295,6 @@ function App() {
setActiveTool('select');
};
// Rewrite existing page text in place via replace_text, which targets stable
// page-object indices (no coordinates) and reflows the rest of the line.
const handleEditText = (pageIndex: number, run: EditableRun, newText: string) => {
if (!can('canModify')) { denyToast('Editing text'); setActiveTool('select'); return; }
applyOps([{
@@ -335,7 +309,6 @@ function App() {
setActiveTool('select');
};
// Whole-paragraph reflow (Word-style wrap + push-down) for multi-line paragraph edits.
const handleReflowParagraph = (pageIndex: number, payload: ReflowParagraphPayload) => {
if (!can('canModify')) { denyToast('Editing text'); setActiveTool('select'); return; }
applyOps([{ id: rid('reflow'), type: 'reflow_paragraph', pageIndex, data: payload }], 'Text reflowed');
@@ -368,7 +341,6 @@ function App() {
setActiveTool('select');
};
/* ---------------------------------------------------------- page ops */
const handleRotate = () => {
if (!activeDoc) return;
if (!can('canAssemble')) { denyToast('Rotating pages'); return; }
@@ -415,7 +387,6 @@ function App() {
});
};
/* ----------------------------------------------------------- doc-level */
const handleUpload = async (file: File, password = '') => {
try {
setIsLoading(true);
@@ -426,7 +397,6 @@ function App() {
setPasswordPrompt(null);
} catch (e) {
if (e instanceof PasswordError) {
// Needs a password (missing or wrong) — prompt and retry.
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
} else {
console.error('Upload failed', e);
@@ -466,8 +436,6 @@ function App() {
setTimeout(() => {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
// Optional: cleanup after a delay
// setTimeout(() => document.body.removeChild(iframe), 10000);
}, 100);
};
document.body.appendChild(iframe);
@@ -493,7 +461,7 @@ function App() {
const fitWidth = () => {
const w = activeDoc?.pageWidth || 612;
const inspectorWidth = isInspectorOpen ? 322 : 0;
const avail = window.innerWidth - inspectorWidth - 48 /*spacing leeway*/;
const avail = window.innerWidth - inspectorWidth - 48 ;
setZoom(Math.max(0.25, Math.min(3, avail / w)));
};
@@ -502,7 +470,6 @@ function App() {
};
const handleDeleteAnnotation = (a: Annotation) => {
// Optimistically drop it from the overlay; the save round-trip makes it authoritative.
setAnnotations((prev) => prev.filter((x) => x.id !== a.id));
applyOps([{
id: rid('delanno'), type: 'delete_annotation', pageIndex: a.pageIndex ?? currentPage,
@@ -511,7 +478,6 @@ function App() {
};
const handleUpdateAnnotation = (a: Annotation) => {
// Optimistically update
setAnnotations((prev) => prev.map((x) => x.id === a.id ? a : x));
applyOps([{
id: rid('updanno'), type: 'update_annotation', pageIndex: a.pageIndex ?? currentPage,
@@ -528,7 +494,6 @@ function App() {
}], 'Annotation updated');
};
/* -------------------------------------------------------------- render */
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
<TopBar
@@ -73,9 +73,7 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
return (
<aside className="flex h-full shrink-0 border-l border-[#ebedf0] bg-[#ffffff]" style={{ width: '322px' }}>
{/* Content */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Header: active section title + compact document switcher */}
<div className="flex h-[48px] shrink-0 items-center justify-between gap-2 border-b border-[#ebedf0]" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
<span className="shrink-0 text-[13px] font-bold text-[#18212e]">{activeDef.label}</span>
{p.documents.length > 0 ? (
@@ -108,7 +106,6 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
)}
</div>
{/* Icon-only tab switcher — evenly spaced so all 7 fit with breathing room */}
<div className="flex shrink-0 items-center justify-between border-b border-[#ebedf0] bg-[#f6f7f9]" style={{ paddingLeft: '10px', paddingRight: '10px', paddingTop: '7px', paddingBottom: '7px' }}>
{TABS.map((t) => {
const active = p.activeTab === t.id;
@@ -136,7 +133,6 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
})}
</div>
{/* Body */}
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
{p.activeTab === 'pages' && <PagesTab {...p} />}
{p.activeTab === 'notes' && <NotesTab annotations={p.annotations.filter((a) => a.type !== 'widget')} onNavigate={p.onNavigateAnnotation} onDelete={p.onDeleteAnnotation} onUpdate={p.onUpdateAnnotation} />}
@@ -396,7 +392,6 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
</div>
))}
{/* Security / permissions */}
<div className="mt-3 border-t border-[#ebedf0] pt-3">
<div className="mb-2 flex items-center gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">Security</span>
+1 -2
View File
@@ -3,7 +3,7 @@ import { CustomButton } from './custom/CustomButton';
export interface PasswordPromptState {
filename: string;
error?: string; // shown on a failed attempt ("Incorrect password…")
error?: string;
}
interface PasswordModalProps {
@@ -16,7 +16,6 @@ export const PasswordModal: React.FC<PasswordModalProps> = ({ state, onSubmit, o
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
// Reset the field and focus whenever the prompt (re)opens.
useEffect(() => {
if (!state) return;
setValue('');
+1 -1
View File
@@ -6,7 +6,7 @@ import { toast } from '../lib/toast';
interface SignatureModalProps {
open: boolean;
onClose: () => void;
onConfirm: (dataUrl: string, aspect: number) => void; // aspect = width/height
onConfirm: (dataUrl: string, aspect: number) => void;
}
type Mode = 'draw' | 'type' | 'upload';
+1 -8
View File
@@ -23,9 +23,6 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
}) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Whether we already have a rendered thumbnail. Used to KEEP the current image visible during a
// re-fetch (e.g. after an edit mints a new documentId) instead of flashing back to "Loading…".
// A ref (not state) so the effect can read it without re-running when the image swaps.
const hasImageRef = useRef(false);
useEffect(() => {
@@ -33,14 +30,11 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
const fetchThumbnail = async () => {
try {
// Only show the spinner on the very first load. On a re-fetch after an edit, keep the
// previous thumbnail on screen and swap it for the new one when it arrives (no flash).
if (!hasImageRef.current) setLoading(true);
// Request a lower resolution/zoom image for the thumbnail
const url = await gatewayService.renderPage({
documentId,
pageIndex,
zoom: 0.2, // Small zoom for thumbnail size
zoom: 0.2,
rotation: 0,
});
@@ -77,7 +71,6 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
alt={`Page ${pageIndex + 1}`}
className="w-full h-full object-cover"
/>
{/* Group Hover Overlay Controls */}
<div className="absolute inset-0 bg-slate-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-2 pointer-events-auto">
<div className="flex justify-end">
{onDelete && totalPages && totalPages > 1 && (
-2
View File
@@ -62,7 +62,6 @@ export const Toolbar: React.FC<ToolbarProps> = ({
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
style={{ paddingLeft: '16px', paddingRight: '16px' }}
>
{/* Active tool identity */}
<div className="flex shrink-0 items-center gap-2">
<span
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
@@ -77,7 +76,6 @@ export const Toolbar: React.FC<ToolbarProps> = ({
</div>
<Divider />
{/* Per-tool controls (no overflow clip — would cut off the active-swatch ring) */}
<div className="flex min-w-0 flex-1 items-center gap-3">
{activeTool === 'select' && <Hint>Drag to select text release to copy, or press <Kbd>Ctrl/ + C</Kbd></Hint>}
{activeTool === 'pan' && <Hint>Drag anywhere to move the page.</Hint>}
-4
View File
@@ -54,7 +54,6 @@ export const TopBar: React.FC<TopBarProps> = ({
className="flex shrink-0 items-center justify-between gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
style={{ height: '56px', paddingLeft: '24px', paddingRight: '24px' }}
>
{/* Left: brand + file menu + doc name */}
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-[#2563eb] text-white shadow-sm">
@@ -92,7 +91,6 @@ export const TopBar: React.FC<TopBarProps> = ({
)}
</div>
{/* Center: history · zoom · rotate · page nav */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5">
<CustomButton variant="icon" label="Undo (Ctrl+Z)" size={34} onClick={onUndo} disabled={!canUndo}><UndoIcon size={18} /></CustomButton>
@@ -143,7 +141,6 @@ export const TopBar: React.FC<TopBarProps> = ({
)}
</div>
{/* Right: save-state + health + export + inspector toggle */}
<div className="flex shrink-0 items-center gap-2">
<SaveState isSaving={isSaving} saved={isDirtySaved} />
<HealthChip healthy={backendHealthy} engineReady={!!engineReady} />
@@ -181,7 +178,6 @@ const SaveState: React.FC<{ isSaving: boolean; saved: boolean }> = ({ isSaving,
};
const HealthChip: React.FC<{ healthy: boolean | null; engineReady: boolean }> = ({ healthy, engineReady }) => {
// Fully connected (gateway up + engine on) → no noise.
if (healthy && engineReady) return null;
let color = '#98a1ad';
-13
View File
@@ -72,7 +72,6 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
}
addLog(`Document loaded successfully. Allocated handle ID: ${docHandle} in ${loadTime.toFixed(1)}ms`);
// Update stats
setStats((prev) => ({
...prev,
docHandle,
@@ -80,7 +79,6 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
loadTimeMs: loadTime,
}));
// Render Page
setStatus('Rendering page client-side...');
addLog(`Calling renderPage(handle: ${docHandle}, pageIndex: ${currentPage}, scale: 1.0) ...`);
@@ -92,7 +90,6 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
addLog(`Page rendered to raw pixel RGBA buffer in ${renderTime.toFixed(1)}ms`);
// Draw onto Canvas
if (canvasRef.current) {
const canvas = canvasRef.current;
canvas.width = imageData.width;
@@ -104,7 +101,6 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
}
}
// Get Text JSON
setStatus('Extracting page text...');
addLog(`Calling getTextJson(handle: ${docHandle}, pageIndex: ${currentPage}) ...`);
@@ -115,7 +111,6 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
addLog(`Text query complete in ${textTime.toFixed(1)}ms`);
// Pretty print JSON
try {
const parsed = JSON.parse(textData);
setTextJson(JSON.stringify(parsed, null, 2));
@@ -154,7 +149,6 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
return (
<div className="flex flex-col w-[400px] border-l border-slate-800 bg-slate-900/90 backdrop-blur-md h-full text-slate-200 overflow-hidden shadow-2xl z-40 animate-in slide-in-from-right duration-300">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-800 bg-slate-950/40">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-indigo-500 animate-pulse shadow-[0_0_8px_#6366f1]" />
@@ -171,9 +165,7 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
</CustomButton>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Status Card */}
<div className="p-3.5 rounded-xl border border-slate-800 bg-slate-950/20">
<div className="text-xs text-slate-400 font-medium">Pipeline Status</div>
<div className="text-sm font-bold text-slate-100 flex items-center gap-2 mt-1">
@@ -182,7 +174,6 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
</div>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 gap-2.5">
<div className="p-3 rounded-lg border border-slate-800 bg-slate-950/10">
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider">Doc Handle ID</div>
@@ -208,7 +199,6 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
</div>
</div>
{/* Client-side Canvas Preview */}
<div className="space-y-1.5">
<div className="text-[11px] font-bold tracking-wider uppercase text-slate-400">Client Canvas Output</div>
<div className="border border-slate-800 rounded-xl bg-slate-950 flex items-center justify-center p-4 overflow-hidden relative min-h-[160px]">
@@ -219,7 +209,6 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
</div>
</div>
{/* Text Bounds JSON Output */}
<div className="flex-1 flex flex-col space-y-1.5 min-h-[220px]">
<div className="text-[11px] font-bold tracking-wider uppercase text-slate-400">extractTextWithBounds() JSON</div>
<div className="flex-1 min-h-[150px] max-h-[300px] border border-slate-800 rounded-xl bg-slate-950 p-3 overflow-y-auto font-mono text-xs text-indigo-300">
@@ -231,7 +220,6 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
</div>
</div>
{/* Console Logs */}
<div className="space-y-1.5">
<div className="text-[11px] font-bold tracking-wider uppercase text-slate-400">Execution Console Logs</div>
<div className="border border-slate-800 rounded-xl bg-slate-950 p-3 max-h-[200px] overflow-y-auto font-mono text-[10px] space-y-1 text-slate-400">
@@ -244,7 +232,6 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
</div>
</div>
{/* Footer / Engine Tag */}
<div className="p-3 border-t border-slate-800 bg-slate-950/60 text-[10px] text-center text-slate-500 font-bold uppercase tracking-widest">
{engineInfo || 'WASM Engine Offline'}
</div>
@@ -25,13 +25,11 @@ export const CustomConfirmationModal: React.FC<CustomConfirmationModalProps> = (
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" onMouseDown={onClose}>
{/* Backdrop */}
<div
className="absolute inset-0 bg-[#0f172a]/30 backdrop-blur-[2px]"
style={{ animation: 'toastIn 0.2s ease-out' }}
/>
{/* Modal Content */}
<div
style={{ width: 440, animation: 'slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-[#ffffff] shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-[#ebedf0]"
-3
View File
@@ -33,7 +33,6 @@ type IC = React.FC<IconProps>;
const mk = (children: React.ReactNode, defStroke = 1.6): IC =>
({ size = 18, className, strokeWidth }) => base(size, className, strokeWidth ?? defStroke, children);
// Tools
export const SelectIcon: IC = mk(<path d="M5 3l14 7-6 1.5L10 18 5 3z" />);
export const PanIcon: IC = mk(<path d="M7 11.5V6a1.5 1.5 0 013 0m0 0v-.5a1.5 1.5 0 013 0V8m0-1a1.5 1.5 0 013 0v4.5a6.5 6.5 0 01-13 0V10a1.5 1.5 0 013 0" />);
export const HighlightIcon: IC = mk(<><path d="M4 20h4l9.5-9.5-3.5-3.5L4.5 16.5 4 20z" /><path d="M14 6.5l3.5 3.5" /></>);
@@ -48,7 +47,6 @@ export const StampIcon: IC = mk(<><path d="M9 12a3 3 0 113 0c-.7.6-1 1.3-1 2v1h-
export const RedactIcon: IC = mk(<rect x="4" y="4" width="16" height="16" rx="1.5" fill="currentColor" stroke="none" />);
export const ImageIcon: IC = mk(<><rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="8.5" cy="9.5" r="1.5" /><path d="M21 17l-5-5L5 21" /></>);
// Top bar
export const UndoIcon: IC = mk(<path d="M9 14L4 9l5-5M4 9h11a5 5 0 010 10h-3" />);
export const RedoIcon: IC = mk(<path d="M15 14l5-5-5-5M20 9H9a5 5 0 000 10h3" />);
export const ZoomInIcon: IC = mk(<><circle cx="11" cy="11" r="7" /><path d="M11 8v6M8 11h6M20 20l-3.5-3.5" /></>);
@@ -63,7 +61,6 @@ export const XIcon: IC = mk(<path d="M6 6l12 12M18 6L6 18" />);
export const ShareIcon: IC = mk(<><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" /></>);
export const FitIcon: IC = mk(<path d="M8 3H5a2 2 0 00-2 2v3m0 8v3a2 2 0 002 2h3m8 0h3a2 2 0 002-2v-3m0-8V5a2 2 0 00-2-2h-3" />);
// Inspector
export const PagesIcon: IC = mk(<><rect x="4" y="4" width="6" height="6" rx="1" /><rect x="14" y="4" width="6" height="6" rx="1" /><rect x="4" y="14" width="6" height="6" rx="1" /><rect x="14" y="14" width="6" height="6" rx="1" /></>);
export const NotesIcon: IC = mk(<><path d="M5 4h14a1 1 0 011 1v11a1 1 0 01-1 1H9l-4 4V5a1 1 0 011-1z" /><path d="M8 9h8M8 12h5" /></>);
export const PropertiesIcon: IC = mk(<><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></>);
+3 -3
View File
@@ -11,9 +11,9 @@ export interface Rect {
}
export interface MappingContext {
zoom: number; // e.g. 1.0 (100%), 1.5 (150%)
dpr: number; // Device Pixel Ratio (window.devicePixelRatio)
rotation: number; // e.g. 0, 90, 180, 270 degrees
zoom: number;
dpr: number;
rotation: number;
scrollX: number;
scrollY: number;
}
+1 -15
View File
@@ -1,23 +1,12 @@
import { gatewayService } from './gatewayService';
// Loads a PDF run's real (embedded or PDFium-substitute) font into the browser via the
// FontFace API so the in-place text editor can render in the document's actual font.
// Falls back to null (→ caller uses a CSS font) when the font isn't browser-loadable.
//
// - One fetch + one document.fonts.add per (documentId, internalFontId), deduped.
// - releaseDocumentFonts(docId) unregisters a document's faces on document change
// (NOT on page change — the same fonts recur across pages).
// key -> promise resolving to the registered CSS family name, or null (use fallback).
const fontPromises = new Map<string, Promise<string | null>>();
// Registered FontFace objects, for cleanup on document change.
const fontFaces = new Map<string, FontFace>();
function keyOf(documentId: string, internalFontId: string): string {
return `${documentId}::${internalFontId}`;
}
// Small stable FNV-1a hash → CSS-safe family token (internalFontId has +/_/spaces).
function familyName(key: string): string {
let h = 2166136261;
for (let i = 0; i < key.length; i++) {
@@ -27,7 +16,6 @@ function familyName(key: string): string {
return `pdf-${(h >>> 0).toString(16)}`;
}
// Resolves to the CSS family name to use, or null if no real font is available.
export function loadPdfFont(documentId: string, internalFontId: string): Promise<string | null> {
if (!internalFontId) return Promise.resolve(null);
const key = keyOf(documentId, internalFontId);
@@ -45,7 +33,7 @@ export function loadPdfFont(documentId: string, internalFontId: string): Promise
fontFaces.set(key, face);
return family;
} catch {
return null; // rejected load (e.g. unsupported format) — keep it out of the registry
return null;
}
})();
@@ -53,7 +41,6 @@ export function loadPdfFont(documentId: string, internalFontId: string): Promise
return p;
}
// Unregister all FontFaces loaded for a document (call when the active document changes).
export function releaseDocumentFonts(documentId: string): void {
const prefix = `${documentId}::`;
for (const [key, face] of Array.from(fontFaces.entries())) {
@@ -61,7 +48,6 @@ export function releaseDocumentFonts(documentId: string): void {
try {
document.fonts.delete(face);
} catch {
/* ignore */
}
fontFaces.delete(key);
}
+7 -37
View File
@@ -1,23 +1,12 @@
// Adobe-grade text-selection model.
//
// Operates purely in **page-point space** (unzoomed, top-left origin) — the same
// space the engine emits glyph bounds in. Callers convert mouse coordinates into
// this space (divide by zoom) before querying, and scale the returned rects back
// up (multiply by zoom) for rendering.
//
// A *caret* is a position between glyphs, indexed 0..N (N = glyph count). A
// selection is an ordered pair of carets {start, end}; start may be greater than
// end for a backward (right-to-left) drag. All range helpers normalise
// internally, so the component is free to keep anchor/focus unnormalised.
import type { Glyph } from './gatewayService';
export interface OrderedGlyph extends Glyph {
index: number; // position in reading order
line: number; // line band this glyph belongs to
right: number; // x + w (cached)
bottom: number; // y + h (cached)
mid: number; // x + w/2 (cached)
index: number;
line: number;
right: number;
bottom: number;
mid: number;
}
export interface CaretRange {
@@ -36,8 +25,8 @@ interface LineBand {
top: number;
bottom: number;
mid: number;
start: number; // first glyph index (inclusive)
end: number; // last glyph index + 1 (exclusive)
start: number;
end: number;
}
const isSpace = (t: string) => t.length === 0 || /^\s+$/.test(t);
@@ -50,19 +39,14 @@ export class TextSelectionModel {
const clean = raw.filter((g) => g.w >= 0 && g.h > 0);
if (clean.length === 0) return;
// Cluster glyphs into lines by vertical overlap. Sort by vertical centre so
// rows arrive top-to-bottom even when the source order is arbitrary.
const byY = [...clean].sort((a, b) => a.y + a.h / 2 - (b.y + b.h / 2));
const rows: Glyph[][] = [];
for (const g of byY) {
const gMid = g.y + g.h / 2;
const row = rows[rows.length - 1];
if (row) {
// Band of the current row so far.
const top = Math.min(...row.map((r) => r.y));
const bottom = Math.max(...row.map((r) => r.y + r.h));
// Same line if the glyph centre falls inside the row band (with a small
// tolerance for sub/superscript jitter).
const tol = g.h * 0.25;
if (gMid >= top - tol && gMid <= bottom + tol) {
row.push(g);
@@ -72,8 +56,6 @@ export class TextSelectionModel {
rows.push([g]);
}
// Flatten rows (each sorted left→right) into a single reading-order array and
// record the line bands for spatial queries.
let idx = 0;
rows.forEach((row, lineNo) => {
row.sort((a, b) => a.x - b.x);
@@ -103,13 +85,11 @@ export class TextSelectionModel {
return this.glyphs.length;
}
/** Index of the line whose band contains y, else the nearest line by centre. */
private lineAt(y: number): number {
if (this.lines.length === 0) return -1;
for (let i = 0; i < this.lines.length; i++) {
if (y >= this.lines[i].top && y <= this.lines[i].bottom) return i;
}
// Above the first / below the last / in an inter-line gap → nearest centre.
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < this.lines.length; i++) {
@@ -122,7 +102,6 @@ export class TextSelectionModel {
return best;
}
/** Nearest caret (0..N) to a point. */
caretAt(x: number, y: number): number {
const li = this.lineAt(y);
if (li < 0) return 0;
@@ -132,13 +111,11 @@ export class TextSelectionModel {
for (let i = line.start; i < line.end; i++) {
const g = this.glyphs[i];
if (x >= g.x && x <= g.right) return x < g.mid ? g.index : g.index + 1;
// In the gap before this glyph.
if (x < g.x) return g.index;
}
return line.end;
}
/** Glyph index directly under a point, or -1. */
glyphAt(x: number, y: number): number {
const li = this.lineAt(y);
if (li < 0) return -1;
@@ -151,11 +128,9 @@ export class TextSelectionModel {
return -1;
}
/** Word caret-range around a point (run of non-space within one line). */
wordRangeAt(x: number, y: number): CaretRange | null {
let gi = this.glyphAt(x, y);
if (gi < 0) {
// Snap to the caret, then take the glyph just right of it (or left at EOL).
const c = this.caretAt(x, y);
gi = c < this.length ? c : c - 1;
if (gi < 0 || gi >= this.length) return null;
@@ -169,7 +144,6 @@ export class TextSelectionModel {
return { start: s, end: e + 1 };
}
/** Whole-line caret-range around a point. */
lineRangeAt(_x: number, y: number): CaretRange | null {
const li = this.lineAt(y);
if (li < 0) return null;
@@ -189,7 +163,6 @@ export class TextSelectionModel {
return this.glyphs.slice(start, end);
}
/** Reconstructed text with intra-line spaces and inter-line newlines. */
textOfRange(r: CaretRange): string {
const sel = this.glyphsInRange(r);
if (sel.length === 0) return '';
@@ -210,7 +183,6 @@ export class TextSelectionModel {
return out.replace(/[ \t]+\n/g, '\n').trimEnd();
}
/** Per-line union rects covering the selection (page-point space). */
rectsOfRange(r: CaretRange): SelRect[] {
const sel = this.glyphsInRange(r);
if (sel.length === 0) return [];
@@ -224,14 +196,12 @@ export class TextSelectionModel {
for (const arr of byLine.values()) {
const x = Math.min(...arr.map((g) => g.x));
const right = Math.max(...arr.map((g) => g.right));
// Use the full line band height for a continuous highlight, not glyph height.
const band = this.lines[arr[0].line];
rects.push({ x, y: band.top, w: right - x, h: band.bottom - band.top });
}
return rects;
}
/** Tight union of all selection rects (page-point space) — for highlight bbox. */
unionRect(r: CaretRange): SelRect | null {
const rects = this.rectsOfRange(r);
if (rects.length === 0) return null;

Some files were not shown because too many files have changed in this diff Show More