content stream

This commit is contained in:
saqib mir
2026-06-16 19:06:48 +05:30
parent 32d6f1379b
commit 7cd400e62a
33 changed files with 2325 additions and 0 deletions
+14
View File
@@ -7,8 +7,18 @@ add_executable(pdfengine_smoke
display_list_test.cpp
document_test.cpp
skia_renderer_test.cpp
lexer_test.cpp
parser_test.cpp
content_builder_test.cpp
ast_serializer_test.cpp
content_serializer_test.cpp
qpdf_writer_test.cpp
)
if(PDFENGINE_WITH_QPDF)
target_sources(pdfengine_smoke PRIVATE qpdf_extractor_test.cpp)
endif()
target_link_libraries(pdfengine_smoke
PRIVATE
pdfengine::pdfengine
@@ -20,6 +30,10 @@ if(PDFENGINE_WITH_SKIA)
target_link_libraries(pdfengine_smoke PRIVATE skia::skia)
endif()
if(MSVC)
target_link_options(pdfengine_smoke PRIVATE "/FORCE:MULTIPLE")
endif()
target_include_directories(pdfengine_smoke
PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/../src"
+63
View File
@@ -0,0 +1,63 @@
#include <gtest/gtest.h>
#include "../src/serializer/ast_serializer.hpp"
using namespace pdfengine;
TEST(AstSerializerTest, SimpleOperations) {
Operation opTd;
opTd.op = "Td";
auto xNode = std::make_shared<AstNode>(AstNodeType::Number);
xNode->numberValue = 10.5;
auto yNode = std::make_shared<AstNode>(AstNodeType::Number);
yNode->numberValue = 20.0;
opTd.operands.push_back(xNode);
opTd.operands.push_back(yNode);
AstSerializer serializer;
std::string result = serializer.serialize({opTd});
EXPECT_EQ(result, "10.5 20 Td\n");
}
TEST(AstSerializerTest, StringEscaping) {
Operation opTj;
opTj.op = "Tj";
auto strNode = std::make_shared<AstNode>(AstNodeType::String);
strNode->stringValue = "Hello (World)";
opTj.operands.push_back(strNode);
AstSerializer serializer;
std::string result = serializer.serialize({opTj});
EXPECT_EQ(result, "(Hello \\(World\\)) Tj\n");
}
TEST(AstSerializerTest, ArraySerialization) {
Operation opTJ;
opTJ.op = "TJ";
auto arrNode = std::make_shared<AstNode>(AstNodeType::Array);
auto str1 = std::make_shared<AstNode>(AstNodeType::String);
str1->stringValue = "He";
auto num = std::make_shared<AstNode>(AstNodeType::Number);
num->numberValue = 120;
auto str2 = std::make_shared<AstNode>(AstNodeType::String);
str2->stringValue = "llo";
arrNode->arrayItems.push_back(str1);
arrNode->arrayItems.push_back(num);
arrNode->arrayItems.push_back(str2);
opTJ.operands.push_back(arrNode);
AstSerializer serializer;
std::string result = serializer.serialize({opTJ});
EXPECT_EQ(result, "[ (He) 120 (llo) ] TJ\n");
}
+119
View File
@@ -0,0 +1,119 @@
#include <gtest/gtest.h>
#include <pdfengine/token.hpp>
#include <pdfengine/ast.hpp>
#include <pdfengine/content_object.hpp>
#include "../src/parser/lexer.hpp"
#include "../src/parser/parser.hpp"
#include "../src/parser/content_builder.hpp"
#include "../src/qpdf/qpdf_extractor.hpp"
#include <filesystem>
#ifndef TEST_CORPUS_DIR
#define TEST_CORPUS_DIR "../../corpus"
#endif
using namespace pdfengine;
TEST(ContentBuilderTest, SimpleTextState) {
Lexer lexer("10 20 Td /F1 12 Tf (Hello) Tj");
auto tokens = lexer.tokenize();
ContentParser parser(tokens);
ContentBuilder builder;
auto ops = parser.parse();
auto objects = builder.build(ops);
ASSERT_EQ(objects.size(), 1);
EXPECT_EQ(objects[0]->getType(), ContentObjectType::Text);
auto* textObj = static_cast<TextObject*>(objects[0].get());
EXPECT_EQ(textObj->text, "Hello");
EXPECT_EQ(textObj->fontName, "F1");
EXPECT_DOUBLE_EQ(textObj->fontSize, 12.0);
EXPECT_DOUBLE_EQ(textObj->tm[4], 10.0);
EXPECT_DOUBLE_EQ(textObj->tm[5], 20.0);
}
TEST(ContentBuilderTest, KerningArrayTJ) {
Lexer lexer("[ (He) 120 (llo) -600 (World) ] TJ");
auto tokens = lexer.tokenize();
ContentParser parser(tokens);
ContentBuilder builder;
auto ops = parser.parse();
auto objects = builder.build(ops);
ASSERT_EQ(objects.size(), 1);
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");
}
TEST(ContentBuilderTest, RotatedTextMatrix) {
Lexer lexer("0 1 -1 0 100 200 Tm (Rotated) Tj");
auto tokens = lexer.tokenize();
ContentParser parser(tokens);
ContentBuilder builder;
auto ops = parser.parse();
auto objects = builder.build(ops);
ASSERT_EQ(objects.size(), 1);
EXPECT_EQ(objects[0]->getType(), ContentObjectType::Text);
auto* textObj = static_cast<TextObject*>(objects[0].get());
EXPECT_EQ(textObj->text, "Rotated");
EXPECT_DOUBLE_EQ(textObj->tm[0], 0.0);
EXPECT_DOUBLE_EQ(textObj->tm[1], 1.0);
EXPECT_DOUBLE_EQ(textObj->tm[2], -1.0);
EXPECT_DOUBLE_EQ(textObj->tm[3], 0.0);
EXPECT_DOUBLE_EQ(textObj->tm[4], 100.0);
EXPECT_DOUBLE_EQ(textObj->tm[5], 200.0);
}
TEST(ContentBuilderTest, IntegrationHelloWorld) {
pdfengine::qpdf_layer::QpdfExtractor extractor;
std::filesystem::path path = std::filesystem::path(TEST_CORPUS_DIR) / "basic" / "hello_world.pdf";
auto stream = extractor.extractPageStream(path.string(), 0);
ASSERT_TRUE(stream.has_value());
Lexer lexer(stream->decodedContent);
auto tokens = lexer.tokenize();
ContentParser parser(tokens);
ContentBuilder builder;
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;
for (const auto& obj : objects) {
if (obj->getType() == ContentObjectType::Text) {
textObjectCount++;
auto* textObj = static_cast<TextObject*>(obj.get());
if (textObj->text == "Hello, world!") {
foundHello = true;
EXPECT_EQ(textObj->fontName, "F1");
EXPECT_DOUBLE_EQ(textObj->fontSize, 12.0);
EXPECT_DOUBLE_EQ(textObj->tm[4], 20.0);
EXPECT_DOUBLE_EQ(textObj->tm[5], 50.0);
} else if (textObj->text == "Goodbye, world!") {
foundGoodbye = true;
EXPECT_EQ(textObj->fontName, "F2");
EXPECT_DOUBLE_EQ(textObj->fontSize, 16.0);
EXPECT_DOUBLE_EQ(textObj->tm[4], 20.0);
EXPECT_DOUBLE_EQ(textObj->tm[5], 100.0);
}
}
}
EXPECT_GE(textObjectCount, 2);
EXPECT_TRUE(foundHello);
EXPECT_TRUE(foundGoodbye);
}
+47
View File
@@ -0,0 +1,47 @@
#include <gtest/gtest.h>
#include "../src/serializer/content_serializer.hpp"
using namespace pdfengine;
TEST(ContentSerializerTest, SerializeTextObject) {
auto textObj = std::make_unique<TextObject>();
textObj->text = "Hello Serialization";
textObj->fontName = "F1";
textObj->fontSize = 14.5;
textObj->tm[0] = 1.0;
textObj->tm[1] = 0.0;
textObj->tm[2] = 0.0;
textObj->tm[3] = 1.0;
textObj->tm[4] = 100.0;
textObj->tm[5] = 200.0;
std::vector<std::unique_ptr<ContentObject>> objects;
objects.push_back(std::move(textObj));
ContentSerializer serializer;
auto ops = serializer.serialize(objects);
ASSERT_EQ(ops.size(), 5);
EXPECT_EQ(ops[0].op, "BT");
EXPECT_EQ(ops[1].op, "Tf");
ASSERT_EQ(ops[1].operands.size(), 2);
EXPECT_EQ(ops[1].operands[0]->stringValue, "F1");
EXPECT_DOUBLE_EQ(ops[1].operands[1]->numberValue, 14.5);
EXPECT_EQ(ops[2].op, "Tm");
ASSERT_EQ(ops[2].operands.size(), 6);
EXPECT_DOUBLE_EQ(ops[2].operands[0]->numberValue, 1.0);
EXPECT_DOUBLE_EQ(ops[2].operands[1]->numberValue, 0.0);
EXPECT_DOUBLE_EQ(ops[2].operands[2]->numberValue, 0.0);
EXPECT_DOUBLE_EQ(ops[2].operands[3]->numberValue, 1.0);
EXPECT_DOUBLE_EQ(ops[2].operands[4]->numberValue, 100.0);
EXPECT_DOUBLE_EQ(ops[2].operands[5]->numberValue, 200.0);
EXPECT_EQ(ops[3].op, "Tj");
ASSERT_EQ(ops[3].operands.size(), 1);
EXPECT_EQ(ops[3].operands[0]->stringValue, "Hello Serialization");
EXPECT_EQ(ops[4].op, "ET");
}
+133
View File
@@ -0,0 +1,133 @@
#include <gtest/gtest.h>
#include <pdfengine/token.hpp>
#include "../src/parser/lexer.hpp"
using namespace pdfengine;
TEST(LexerTest, OperatorsAndWhitespace) {
Lexer lexer("BT\n/F1 12 Tf\nET");
auto tokens = lexer.tokenize();
ASSERT_EQ(tokens.size(), 5);
EXPECT_EQ(tokens[0].type, TokenType::Operator);
EXPECT_EQ(tokens[0].stringValue, "BT");
EXPECT_EQ(tokens[1].type, TokenType::Name);
EXPECT_EQ(tokens[1].stringValue, "F1");
EXPECT_EQ(tokens[2].type, TokenType::Number);
EXPECT_EQ(tokens[2].numberValue, 12.0);
EXPECT_EQ(tokens[3].type, TokenType::Operator);
EXPECT_EQ(tokens[3].stringValue, "Tf");
EXPECT_EQ(tokens[4].type, TokenType::Operator);
EXPECT_EQ(tokens[4].stringValue, "ET");
}
TEST(LexerTest, Strings) {
Lexer lexer("(Hello World) (Nested (parens) ok) (Escapes \\n \\t \\\\ \\(\\)) (Octal \\053)");
auto tokens = lexer.tokenize();
ASSERT_EQ(tokens.size(), 4);
EXPECT_EQ(tokens[0].type, TokenType::String);
EXPECT_EQ(tokens[0].stringValue, "Hello World");
EXPECT_EQ(tokens[1].type, TokenType::String);
EXPECT_EQ(tokens[1].stringValue, "Nested (parens) ok");
EXPECT_EQ(tokens[2].type, TokenType::String);
EXPECT_EQ(tokens[2].stringValue, "Escapes \n \t \\ ()");
EXPECT_EQ(tokens[3].type, TokenType::String);
EXPECT_EQ(tokens[3].stringValue, "Octal +"); // \053 is '+'
}
TEST(LexerTest, HexStrings) {
Lexer lexer("<48 656c 6c6F> <4A5>");
auto tokens = lexer.tokenize();
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);
}
TEST(LexerTest, NamesAndNumbers) {
Lexer lexer("/Name1 /A#20B -3.14 .5 100");
auto tokens = lexer.tokenize();
ASSERT_EQ(tokens.size(), 5);
EXPECT_EQ(tokens[0].type, TokenType::Name);
EXPECT_EQ(tokens[0].stringValue, "Name1");
EXPECT_EQ(tokens[1].type, TokenType::Name);
EXPECT_EQ(tokens[1].stringValue, "A B");
EXPECT_EQ(tokens[2].type, TokenType::Number);
EXPECT_DOUBLE_EQ(tokens[2].numberValue, -3.14);
EXPECT_EQ(tokens[3].type, TokenType::Number);
EXPECT_DOUBLE_EQ(tokens[3].numberValue, 0.5);
EXPECT_EQ(tokens[4].type, TokenType::Number);
EXPECT_DOUBLE_EQ(tokens[4].numberValue, 100.0);
}
#include "../src/qpdf/qpdf_extractor.hpp"
#include <filesystem>
#ifndef TEST_CORPUS_DIR
#define TEST_CORPUS_DIR "../../corpus"
#endif
TEST(LexerTest, DictAndArray) {
Lexer lexer("<< /Type /Page >> [ 1 2 3 ]");
auto tokens = lexer.tokenize();
ASSERT_EQ(tokens.size(), 9);
EXPECT_EQ(tokens[0].type, TokenType::DictStart);
EXPECT_EQ(tokens[1].type, TokenType::Name);
EXPECT_EQ(tokens[2].type, TokenType::Name);
EXPECT_EQ(tokens[3].type, TokenType::DictEnd);
EXPECT_EQ(tokens[4].type, TokenType::ArrayStart);
EXPECT_EQ(tokens[5].type, TokenType::Number);
EXPECT_EQ(tokens[6].type, TokenType::Number);
EXPECT_EQ(tokens[7].type, TokenType::Number);
EXPECT_EQ(tokens[8].type, TokenType::ArrayEnd);
}
TEST(LexerTest, IntegrationHelloWorld) {
pdfengine::qpdf_layer::QpdfExtractor extractor;
std::filesystem::path path = std::filesystem::path(TEST_CORPUS_DIR) / "basic" / "hello_world.pdf";
auto stream = extractor.extractPageStream(path.string(), 0);
ASSERT_TRUE(stream.has_value());
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");
break;
}
}
EXPECT_TRUE(foundHello) << "Failed to lex (Hello, world!) from hello_world.pdf stream";
}
+117
View File
@@ -0,0 +1,117 @@
#include <gtest/gtest.h>
#include <pdfengine/token.hpp>
#include <pdfengine/ast.hpp>
#include "../src/parser/parser.hpp"
#include "../src/parser/lexer.hpp"
#include "../src/qpdf/qpdf_extractor.hpp"
#include <filesystem>
#ifndef TEST_CORPUS_DIR
#define TEST_CORPUS_DIR "../../corpus"
#endif
using namespace pdfengine;
TEST(ParserTest, SimpleOperation) {
Lexer lexer("10 20 Td");
auto tokens = lexer.tokenize();
ContentParser parser(tokens);
auto ops = parser.parse();
ASSERT_EQ(ops.size(), 1);
EXPECT_EQ(ops[0].op, "Td");
ASSERT_EQ(ops[0].operands.size(), 2);
EXPECT_EQ(ops[0].operands[0]->type, AstNodeType::Number);
EXPECT_DOUBLE_EQ(ops[0].operands[0]->numberValue, 10.0);
EXPECT_EQ(ops[0].operands[1]->type, AstNodeType::Number);
EXPECT_DOUBLE_EQ(ops[0].operands[1]->numberValue, 20.0);
}
TEST(ParserTest, ArraysAndDicts) {
Lexer lexer("<< /Type /Page >> [ 1 2 ] (Text) Tj");
auto tokens = lexer.tokenize();
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");
}
TEST(ParserTest, MultipleOperations) {
Lexer lexer("BT /F1 12 Tf (Hello) Tj ET");
auto tokens = lexer.tokenize();
ContentParser parser(tokens);
auto ops = parser.parse();
ASSERT_EQ(ops.size(), 4);
EXPECT_EQ(ops[0].op, "BT");
EXPECT_TRUE(ops[0].operands.empty());
EXPECT_EQ(ops[1].op, "Tf");
ASSERT_EQ(ops[1].operands.size(), 2);
EXPECT_EQ(ops[1].operands[0]->stringValue, "F1");
EXPECT_DOUBLE_EQ(ops[1].operands[1]->numberValue, 12.0);
EXPECT_EQ(ops[2].op, "Tj");
ASSERT_EQ(ops[2].operands.size(), 1);
EXPECT_EQ(ops[2].operands[0]->stringValue, "Hello");
EXPECT_EQ(ops[3].op, "ET");
EXPECT_TRUE(ops[3].operands.empty());
}
TEST(ParserTest, IntegrationHelloWorld) {
pdfengine::qpdf_layer::QpdfExtractor extractor;
std::filesystem::path path = std::filesystem::path(TEST_CORPUS_DIR) / "basic" / "hello_world.pdf";
auto stream = extractor.extractPageStream(path.string(), 0);
ASSERT_TRUE(stream.has_value());
Lexer lexer(stream->decodedContent);
auto tokens = lexer.tokenize();
ContentParser parser(tokens);
auto ops = parser.parse();
bool foundTj = false;
for (const auto& op : ops) {
if (op.op == "Tj" || op.op == "TJ") {
ASSERT_EQ(op.operands.size(), 1);
if (op.operands[0]->type == AstNodeType::String &&
op.operands[0]->stringValue == "Hello, world!") {
foundTj = true;
break;
}
}
}
EXPECT_TRUE(foundTj) << "Failed to parse (Hello, world!) Tj operation from hello_world.pdf stream";
}
+94
View File
@@ -0,0 +1,94 @@
#include <gtest/gtest.h>
#include <pdfengine/content_stream.hpp>
#include "../src/qpdf/qpdf_extractor.hpp"
#include <filesystem>
#include <fstream>
using namespace pdfengine;
using namespace pdfengine::qpdf_layer;
#ifndef TEST_CORPUS_DIR
#define TEST_CORPUS_DIR "../../corpus"
#endif
namespace {
std::filesystem::path getCorpusPath(const std::string& subfolder, const std::string& filename) {
return std::filesystem::path(TEST_CORPUS_DIR) / subfolder / filename;
}
std::vector<uint8_t> readFile(const std::filesystem::path& path) {
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file.is_open()) return {};
std::streamsize size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<uint8_t> buffer(size);
if (file.read(reinterpret_cast<char*>(buffer.data()), size)) {
return buffer;
}
return {};
}
}
class QpdfExtractorTest : public ::testing::Test {
protected:
QpdfExtractor extractor;
};
TEST_F(QpdfExtractorTest, ExtractFromMemory) {
auto path = getCorpusPath("basic", "hello_world.pdf");
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
StreamVerification v = verifyContentStream(stream.value());
EXPECT_TRUE(v.hasBT);
EXPECT_TRUE(v.hasET);
EXPECT_TRUE(v.hasTf);
EXPECT_TRUE(v.hasTj);
}
TEST_F(QpdfExtractorTest, PageOutOfBounds) {
auto path = getCorpusPath("basic", "hello_world.pdf");
auto data = readFile(path);
ASSERT_FALSE(data.empty());
auto stream = extractor.extractPageStreamFromMemory(data, 1);
ASSERT_FALSE(stream.has_value());
EXPECT_EQ(stream.error(), QpdfError::PageOutOfBounds);
stream = extractor.extractPageStreamFromMemory(data, -1);
ASSERT_FALSE(stream.has_value());
EXPECT_EQ(stream.error(), QpdfError::PageOutOfBounds);
}
TEST_F(QpdfExtractorTest, CorruptPdf) {
std::vector<uint8_t> data = {0x00, 0x01, 0x02};
auto stream = extractor.extractPageStreamFromMemory(data, 0);
ASSERT_FALSE(stream.has_value());
EXPECT_EQ(stream.error(), QpdfError::InvalidFormat);
}
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());
EXPECT_EQ(stream->pageIndex, 0);
StreamVerification v = verifyContentStream(stream.value());
EXPECT_FALSE(v.hasTj);
}
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());
StreamVerification v = verifyContentStream(stream.value());
EXPECT_FALSE(v.hasBT);
EXPECT_FALSE(v.hasET);
EXPECT_FALSE(v.hasTf);
EXPECT_FALSE(v.hasTj);
}
+88
View File
@@ -0,0 +1,88 @@
#include <gtest/gtest.h>
#include "../src/qpdf/qpdf_extractor.hpp"
#include "../src/qpdf/qpdf_writer.hpp"
#include "../src/parser/lexer.hpp"
#include "../src/parser/parser.hpp"
#include "../src/parser/content_builder.hpp"
#include "../src/serializer/content_serializer.hpp"
#include "../src/serializer/ast_serializer.hpp"
#include <filesystem>
#include <fstream>
#ifndef TEST_CORPUS_DIR
#define TEST_CORPUS_DIR "../../corpus"
#endif
using namespace pdfengine;
using namespace pdfengine::qpdf_layer;
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);
auto ops = parser.parse();
ContentBuilder builder;
auto objects = builder.build(ops);
// 3. Modify Text
bool foundAndModified = false;
for (auto& obj : objects) {
if (obj->getType() == ContentObjectType::Text) {
auto* textObj = static_cast<TextObject*>(obj.get());
if (textObj->text == "Hello, world!") {
textObj->text = "Hello, PDF Editor!";
foundAndModified = true;
break;
}
}
}
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());
Lexer verifyLexer(verifyStream->decodedContent);
auto verifyTokens = verifyLexer.tokenize();
ContentParser verifyParser(verifyTokens);
ContentBuilder verifyBuilder;
auto verifyObjects = verifyBuilder.build(verifyParser.parse());
bool verifiedModification = false;
for (auto& obj : verifyObjects) {
if (obj->getType() == ContentObjectType::Text) {
auto* textObj = static_cast<TextObject*>(obj.get());
if (textObj->text == "Hello, PDF Editor!") {
verifiedModification = true;
break;
}
}
}
EXPECT_TRUE(verifiedModification) << "Modified string was not successfully saved and reloaded!";
// Cleanup
std::filesystem::remove(destPath);
}