lib/pdf/src/content.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const alloc_arena = @import("alloc_arena");
  3 
  4 const font = @import("font.zig");
  5 const object = @import("object.zig");
  6 
  7 const Piece = union(enum) {
  8     text: object.String,
  9     space,
 10 };
 11 
 12 pub fn extractTextAppend(allocator: std.mem.Allocator, out: *std.ArrayList(u8), content: []const u8, fonts: font.Set) !void {
 13     var parser = object.Parser.init(content, 0);
 14     var pending_strings: std.ArrayList(Piece) = .empty;
 15     defer pending_strings.deinit(allocator);
 16     var arena_state = alloc_arena.Arena.init(allocator);
 17     defer arena_state.deinit();
 18     const arena = arena_state.allocator();
 19     var last_name: ?[]const u8 = null;
 20     var current_font: ?*const font.Font = null;
 21     for (content) |_| {
 22         parser.skipWhitespace();
 23         if (parser.pos >= content.len) break;
 24         const byte = content[parser.pos];
 25         if (byte == '/' or byte == '(' or byte == '<' or byte == '[' or std.ascii.isDigit(byte) or byte == '+' or byte == '-' or byte == '.') {
 26             const value = parser.parseValue(arena) catch {
 27                 parser.pos += 1;
 28                 continue;
 29             };
 30             switch (value) {
 31                 .name => |name| last_name = name,
 32                 else => {},
 33             }
 34             try collectStrings(allocator, &pending_strings, value);
 35             continue;
 36         }
 37         const start = parser.pos;
 38         while (parser.pos < content.len and !object.delimiterOrWhitespace(content[parser.pos])) parser.pos += 1;
 39         if (parser.pos == start) {
 40             parser.pos += 1;
 41             continue;
 42         }
 43         const operator = content[start..parser.pos];
 44         if (std.mem.eql(u8, operator, "Tf")) {
 45             current_font = if (last_name) |name| fonts.get(name) else null;
 46         }
 47         try applyOperator(allocator, out, &pending_strings, operator, current_font);
 48     }
 49 }
 50 
 51 fn collectStrings(allocator: std.mem.Allocator, pending: *std.ArrayList(Piece), value: object.Value) !void {
 52     switch (value) {
 53         .string => |string| try pending.append(allocator, .{ .text = string }),
 54         .array => |items| {
 55             for (items) |item| {
 56                 switch (item) {
 57                     .string => |string| try pending.append(allocator, .{ .text = string }),
 58                     .integer => |kern| if (kern <= -space_kerning_threshold) try pending.append(allocator, .space),
 59                     .real => |kern| if (kern <= -@as(f64, space_kerning_threshold)) try pending.append(allocator, .space),
 60                     else => {},
 61                 }
 62             }
 63         },
 64         else => {},
 65     }
 66 }
 67 
 68 const space_kerning_threshold = 180;
 69 
 70 fn applyOperator(allocator: std.mem.Allocator, out: *std.ArrayList(u8), pending: *std.ArrayList(Piece), operator: []const u8, current_font: ?*const font.Font) !void {
 71     if (std.mem.eql(u8, operator, "Tj") or std.mem.eql(u8, operator, "'") or std.mem.eql(u8, operator, "\"") or std.mem.eql(u8, operator, "TJ")) {
 72         if (std.mem.eql(u8, operator, "'") or std.mem.eql(u8, operator, "\"")) {
 73             try breakLine(allocator, out);
 74         }
 75         for (pending.items) |piece| {
 76             switch (piece) {
 77                 .text => |string| try appendString(allocator, out, string, current_font),
 78                 .space => {
 79                     if (out.items.len > 0 and out.items[out.items.len - 1] != ' ' and out.items[out.items.len - 1] != '\n') {
 80                         try out.append(allocator, ' ');
 81                     }
 82                 },
 83             }
 84         }
 85         pending.clearRetainingCapacity();
 86         return;
 87     }
 88     if (std.mem.eql(u8, operator, "Td") or std.mem.eql(u8, operator, "TD") or std.mem.eql(u8, operator, "T*") or std.mem.eql(u8, operator, "ET")) {
 89         try breakLine(allocator, out);
 90         pending.clearRetainingCapacity();
 91         return;
 92     }
 93     pending.clearRetainingCapacity();
 94 }
 95 
 96 fn appendString(allocator: std.mem.Allocator, out: *std.ArrayList(u8), string: object.String, current_font: ?*const font.Font) !void {
 97     var raw = std.ArrayList(u8).empty;
 98     defer raw.deinit(allocator);
 99     switch (string.kind) {
100         .literal => try object.decodeLiteralAppend(allocator, &raw, string.raw),
101         .hex => try object.decodeHexAppend(allocator, &raw, string.raw),
102     }
103     try font.appendText(current_font, allocator, out, raw.items);
104 }
105 
106 fn breakLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8)) !void {
107     if (out.items.len == 0) return;
108     if (out.items[out.items.len - 1] == '\n') return;
109     try out.append(allocator, '\n');
110 }
111 
112 test "content extraction reads show operators lines and tj arrays" {
113     var out = std.ArrayList(u8).empty;
114     defer out.deinit(std.testing.allocator);
115     const content =
116         "BT /F1 12 Tf 72 700 Td (Hello, ) Tj (world) Tj T* (second line) Tj " ++
117         "0 -14 Td [(kerned) -250 (array) 12 (Close)] TJ ET";
118     try extractTextAppend(std.testing.allocator, &out, content, .{});
119     try std.testing.expectEqualStrings("Hello, world\nsecond line\nkerned arrayClose\n", out.items);
120 }
121 
122 test "content extraction survives unknown operators and hex strings" {
123     var out = std.ArrayList(u8).empty;
124     defer out.deinit(std.testing.allocator);
125     try extractTextAppend(std.testing.allocator, &out, "q 1 0 0 1 10 10 cm BT <48690A> Tj ET Q", .{});
126     try std.testing.expectEqualStrings("Hi\n", out.items);
127 }
128 
129 test "content extraction maps codes through the selected font" {
130     const cmap = @import("cmap.zig");
131     var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
132     defer arena_state.deinit();
133     const map = try cmap.parseAlloc(
134         arena_state.allocator(),
135         "1 begincodespacerange <00> <FF> endcodespacerange " ++
136             "1 beginbfrange <20> <7E> <0020> endbfrange " ++
137             "1 beginbfchar <02> <00660069> endbfchar",
138     );
139     const mapped = font.Font{ .map = map };
140     const plain = font.Font{};
141     const set = font.Set{ .entries = &.{
142         .{ .name = "F1", .font = &mapped },
143         .{ .name = "F2", .font = &plain },
144     } };
145     var out = std.ArrayList(u8).empty;
146     defer out.deinit(std.testing.allocator);
147     try extractTextAppend(
148         std.testing.allocator,
149         &out,
150         "BT /F1 9 Tf (Arti\\002cial) Tj /F2 9 Tf ( raw\\002) Tj ET",
151         set,
152     );
153     try std.testing.expectEqualStrings("Artificial raw\x02\n", out.items);
154 }