diff --git a/lib/nutrient_dws/processor/client.rb b/lib/nutrient_dws/processor/client.rb index 4c7508b..06eea53 100644 --- a/lib/nutrient_dws/processor/client.rb +++ b/lib/nutrient_dws/processor/client.rb @@ -176,6 +176,220 @@ def redact(file:, text: []) make_request(instructions, files: { 'document' => file }) end + def duplicate_pages(file:, page: nil, start_page: nil, end_page: nil) + parts = [] + + if page + # Duplicate a single page + parts << build_part_with_pages(file, 'document', page, page) + elsif start_page && end_page + # Duplicate a page range + parts << build_part_with_pages(file, 'document', start_page, end_page) + else + # Duplicate the entire document (no page specification) + parts << build_part(file, 'document') + end + + # Add the original document as well to create the duplication effect + parts << build_part(file, 'document') + + instructions = { + parts: parts + } + + make_request(instructions, files: { 'document' => file }) + end + + def delete_pages(file:, page: nil, start_page: nil, end_page: nil, keep_before: nil, keep_after: nil) + parts = [] + + if page + # Delete a single page - keep pages before and after + if page > 0 + parts << build_part_with_pages(file, 'document', 0, page - 1) + end + parts << build_part_with_pages(file, 'document', page + 1, -1) + elsif start_page && end_page + # Delete a page range - keep pages before and after the range + if start_page > 0 + parts << build_part_with_pages(file, 'document', 0, start_page - 1) + end + parts << build_part_with_pages(file, 'document', end_page + 1, -1) + elsif keep_before + # Keep pages before a certain point (delete from that point onwards) + parts << build_part_with_pages(file, 'document', 0, keep_before - 1) + elsif keep_after + # Keep pages after a certain point (delete from beginning to that point) + parts << build_part_with_pages(file, 'document', keep_after + 1, -1) + else + raise ArgumentError, 'Must specify pages to delete or pages to keep' + end + + # Remove empty parts + parts = parts.reject { |part| part.dig(:pages, :start) == part.dig(:pages, :end) && part.dig(:pages, :start) == -1 } + + instructions = { + parts: parts + } + + make_request(instructions, files: { 'document' => file }) + end + + def flatten(file:) + instructions = { + parts: [ + build_part(file, 'document') + ], + actions: [ + { + type: 'flatten' + } + ] + } + + make_request(instructions, files: { 'document' => file }) + end + + def rotate(file:, rotate_by:) + unless [90, 180, 270].include?(rotate_by) + raise ArgumentError, "Invalid rotation angle: #{rotate_by}. Supported angles: 90, 180, 270" + end + + instructions = { + parts: [ + build_part(file, 'document') + ], + actions: [ + { + type: 'rotate', + rotateBy: rotate_by + } + ] + } + + make_request(instructions, files: { 'document' => file }) + end + + def add_page(file:, position: :end, after_page: nil, page_count: 1, page_size: 'Letter') + parts = [] + + if position == :beginning + # Add new page(s) at the beginning + parts << build_new_page_part(page_count, page_size) + parts << build_part(file, 'document') + elsif position == :end + # Add new page(s) at the end + parts << build_part(file, 'document') + parts << build_new_page_part(page_count, page_size) + elsif after_page + # Add new page(s) after a specific page + parts << build_part_with_pages(file, 'document', 0, after_page) + parts << build_new_page_part(page_count, page_size) + parts << build_part_with_pages(file, 'document', after_page + 1, -1) + else + raise ArgumentError, 'Must specify position (:beginning, :end) or after_page' + end + + instructions = { + parts: parts + } + + make_request(instructions, files: { 'document' => file }) + end + + def set_page_label(file:, labels:) + # Convert labels to the API format + formatted_labels = labels.map do |label| + if label[:page] + { + pages: { start: label[:page], end: label[:page] }, + label: label[:label] + } + elsif label[:start_page] && label[:end_page] + { + pages: { start: label[:start_page], end: label[:end_page] }, + label: label[:label] + } + else + raise ArgumentError, 'Each label must specify either :page or :start_page and :end_page' + end + end + + instructions = { + parts: [ + build_part(file, 'document') + ], + output: { + type: 'pdf', + labels: formatted_labels + } + } + + make_request(instructions, files: { 'document' => file }) + end + + def json_import(file:, json_data: nil, json_file: nil) + raise ArgumentError, 'Either json_data or json_file must be provided' if json_data.nil? && json_file.nil? + + files = { 'document' => file } + + if json_data + # Create a temporary file for the JSON data + require 'tempfile' + temp_file = Tempfile.new(['annotations', '.json']) + + # Handle both Hash and String inputs + if json_data.is_a?(Hash) + temp_file.write(JSON.dump(json_data)) + else + temp_file.write(json_data.to_s) + end + + temp_file.close + files['annotations.json'] = temp_file.path + elsif json_file + files['annotations.json'] = json_file + end + + instructions = { + parts: [ + build_part(file, 'document') + ], + actions: [ + { + type: 'applyInstantJson', + file: 'annotations.json' + } + ] + } + + result = make_request(instructions, files: files) + + # Clean up temporary file if created + if json_data && temp_file + temp_file.close + temp_file.unlink + end + + result + end + + def xfdf_import(file:, xfdf_file:) + instructions = { + parts: [ + build_part(file, 'document') + ], + actions: [ + { + type: 'applyXfdf', + file: 'xfdf_data' + } + ] + } + + make_request(instructions, files: { 'document' => file, 'xfdf_data' => xfdf_file }) + end + private def parse_page_ranges(ranges) @@ -201,6 +415,28 @@ def build_part(file, file_key) end end + def build_part_with_pages(file, file_key, start_page, end_page) + part = build_part(file, file_key) + + if url?(file) + part[:file][:pages] = { start: start_page, end: end_page } + else + part[:pages] = { start: start_page, end: end_page } + end + + part + end + + def build_new_page_part(page_count, page_size) + { + page: 'new', + pageCount: page_count, + layout: { + size: page_size + } + } + end + def url?(file) file.to_s.match?(%r{\Ahttps?://}) end diff --git a/spec/fixtures/annotations.json b/spec/fixtures/annotations.json new file mode 100644 index 0000000..fb4d068 --- /dev/null +++ b/spec/fixtures/annotations.json @@ -0,0 +1,144 @@ +{ + "annotations": [ + { + "backgroundColor": "#2293FB", + "bbox": [50, 50, 150, 150], + "borderWidth": 0, + "createdAt": "1970-01-01T00:00:00Z", + "font": "Helvetica", + "fontColor": "#FFFFFF", + "fontSize": 18, + "fontStyle": ["bold"], + "horizontalAlign": "center", + "id": "01HZ5BHHGEF82KD85CBWZ2CVY6", + "isFitting": true, + "lineHeightFactor": 1.190000057220459, + "name": "01HZ5BHHGEF82KD85CBWZ2CVY6", + "opacity": 1, + "pageIndex": 0, + "rotation": 0, + "text": { + "format": "plain", + "value": "Welcome to\nPSPDFKit" + }, + "type": "pspdfkit/text", + "updatedAt": "1970-01-01T00:00:00Z", + "v": 2, + "verticalAlign": "center" + }, + { + "bbox": [50, 50, 150, 50], + "createdAt": "1970-01-01T00:00:00Z", + "id": "01HZ5BHHGEJEXECJJ9H4R70WJV", + "isDrawnNaturally": false, + "lineWidth": 5, + "lines": { + "intensities": [ + [0.5, 0.5], + [0.5, 0.5], + [0.5, 0.5] + ], + "points": [ + [ + [50, 50], + [200, 50] + ], + [ + [50, 75], + [200, 75] + ], + [ + [50, 100], + [200, 100] + ] + ] + }, + "name": "01HZ5BHHGEJEXECJJ9H4R70WJV", + "opacity": 1, + "pageIndex": 1, + "strokeColor": "#FFFFFF", + "type": "pspdfkit/ink", + "updatedAt": "1970-01-01T00:00:00Z", + "v": 2 + }, + { + "bbox": [390, 380, 120, 120], + "createdAt": "1970-01-01T00:00:00Z", + "id": "01HZ5BHHGEVA0GBYDSXEV6DBHA", + "name": "01HZ5BHHGEVA0GBYDSXEV6DBHA", + "opacity": 1, + "pageIndex": 0, + "strokeColor": "#2293FB", + "strokeWidth": 5, + "type": "pspdfkit/shape/ellipse", + "updatedAt": "1970-01-01T00:00:00Z", + "v": 2 + }, + { + "bbox": [500, 20, 30, 30], + "color": "#FFD83F", + "createdAt": "1970-01-01T00:00:00Z", + "icon": "comment", + "id": "01HZ5BHHGFGP0TV6CSPQ16BX5M", + "name": "01HZ5BHHGFGP0TV6CSPQ16BX5M", + "opacity": 1, + "pageIndex": 0, + "text": { + "format": "plain", + "value": "An example for a Note Annotation" + }, + "type": "pspdfkit/note", + "updatedAt": "1970-01-01T00:00:00Z", + "v": 2 + }, + { + "bbox": [30, 424, 223, 83], + "blendMode": "multiply", + "color": "#FCEE7C", + "createdAt": "1970-01-01T00:00:00Z", + "id": "01HZ5BHHGFTYARCZGKGZXW2AYZ", + "name": "01HZ5BHHGFTYARCZGKGZXW2AYZ", + "opacity": 1, + "pageIndex": 0, + "rects": [ + [30, 424, 223, 42], + [30, 465, 122, 42] + ], + "type": "pspdfkit/markup/highlight", + "updatedAt": "1970-01-01T00:00:00Z", + "v": 2 + }, + { + "action": { + "type": "uri", + "uri": "http://www.ribrand.si/en/set-of-utensils.html" + }, + "bbox": [28.346500396728516, 695, 100.84251403808594, 20.4920654296875], + "borderStyle": "solid", + "borderWidth": 1, + "createdAt": "2024-05-21T08:36:34Z", + "creatorName": "Button 1", + "formFieldName": "Button 1", + "id": "7QHT8EFNNHD1DAEG3Y6A65F8C5", + "opacity": 1, + "pageIndex": 0, + "rotation": 0, + "type": "pspdfkit/widget", + "updatedAt": "2024-05-21T08:36:34Z", + "v": 2 + } + ], + "formFields": [ + { + "annotationIds": ["7QHT8EFNNHD1DAEG3Y6A65F8C5"], + "buttonLabel": "", + "id": "7QHT8EFNQ7JRNJYJ3N2RD56WJJ", + "label": "Button 1", + "name": "Button 1", + "pdfObjectId": 94, + "type": "pspdfkit/form-field/button", + "v": 1 + } + ], + "format": "https://pspdfkit.com/instant-json/v1" +} diff --git a/spec/fixtures/annotations.xfdf b/spec/fixtures/annotations.xfdf new file mode 100644 index 0000000..e2109c5 --- /dev/null +++ b/spec/fixtures/annotations.xfdf @@ -0,0 +1,43 @@ + + + + + + 50.000000,742.000000;200.000000,742.000000 + 0.500000;0.500000 + 50.000000,717.000000;200.000000,717.000000 + 0.500000;0.500000 + 50.000000,692.000000;200.000000,692.000000 + 0.500000;0.500000 + + + + + An example for a Note Annotation + + +

An example for a Note Annotation

+ +
+
+ + + + Welcome to +PSPDFKit + + +

Welcome to +PSPDFKit

+ +
+ /Helvetica 18 Tf 1 1 1 rg + font:18.00pt "Helvetica"; font-weight:bold; text-align:center; vertical-align:middle; color:#FFFFFF; +
+
+ + +
diff --git a/spec/nutrient_dws/processor/client_spec.rb b/spec/nutrient_dws/processor/client_spec.rb index 9fdbfa5..97e2e7d 100644 --- a/spec/nutrient_dws/processor/client_spec.rb +++ b/spec/nutrient_dws/processor/client_spec.rb @@ -207,6 +207,172 @@ end end + describe '#duplicate_pages' do + it 'successfully duplicates a single page' do + pdf_result = client.duplicate_pages(file: pdf_file, page: 0) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully duplicates a page range' do + pdf_result = client.duplicate_pages(file: pdf_file, start_page: 0, end_page: 0) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully duplicates the entire document' do + pdf_result = client.duplicate_pages(file: pdf_file) + expect(pdf_result).to be_a_valid_pdf + end + + it 'handles negative page indexing' do + pdf_result = client.duplicate_pages(file: pdf_file, page: -1) + expect(pdf_result).to be_a_valid_pdf + end + end + + describe '#delete_pages' do + it 'successfully deletes a single page' do + pdf_result = client.delete_pages(file: pdf_file, page: 0) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully deletes a page range' do + pdf_result = client.delete_pages(file: pdf_file, start_page: 0, end_page: 0) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully keeps pages before a cutoff point' do + pdf_result = client.delete_pages(file: pdf_file, keep_before: 1) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully keeps pages after a cutoff point' do + pdf_result = client.delete_pages(file: pdf_file, keep_after: 0) + expect(pdf_result).to be_a_valid_pdf + end + + it 'raises an error when no deletion criteria provided' do + expect do + client.delete_pages(file: pdf_file) + end.to raise_error(ArgumentError, 'Must specify pages to delete or pages to keep') + end + end + + describe '#flatten' do + it 'successfully flattens a PDF' do + pdf_result = client.flatten(file: pdf_file) + expect(pdf_result).to be_a_valid_pdf + end + + it 'accepts File objects' do + File.open(pdf_file, 'rb') do |file| + pdf_result = client.flatten(file: file) + expect(pdf_result).to be_a_valid_pdf + end + end + end + + describe '#rotate' do + it 'successfully rotates a PDF by 90 degrees' do + pdf_result = client.rotate(file: pdf_file, rotate_by: 90) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully rotates a PDF by 180 degrees' do + pdf_result = client.rotate(file: pdf_file, rotate_by: 180) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully rotates a PDF by 270 degrees' do + pdf_result = client.rotate(file: pdf_file, rotate_by: 270) + expect(pdf_result).to be_a_valid_pdf + end + + it 'raises an error for invalid rotation angle' do + expect do + client.rotate(file: pdf_file, rotate_by: 45) + end.to raise_error(ArgumentError, /Invalid rotation angle/) + end + end + + describe '#add_page' do + it 'successfully adds a blank page at the beginning' do + pdf_result = client.add_page(file: pdf_file, position: :beginning) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully adds a blank page at the end' do + pdf_result = client.add_page(file: pdf_file, position: :end) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully adds multiple blank pages' do + pdf_result = client.add_page(file: pdf_file, position: :end, page_count: 3) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully adds a page with custom size' do + pdf_result = client.add_page(file: pdf_file, position: :end, page_size: 'A4') + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully adds a page at a specific position' do + pdf_result = client.add_page(file: pdf_file, after_page: 0) + expect(pdf_result).to be_a_valid_pdf + end + end + + describe '#set_page_label' do + it 'successfully sets a label for a single page' do + pdf_result = client.set_page_label(file: pdf_file, labels: [{ page: 0, label: 'i' }]) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully sets labels for multiple pages' do + labels = [ + { page: 0, label: 'i' }, + { start_page: 1, end_page: 2, label: '1' } + ] + pdf_result = client.set_page_label(file: pdf_file, labels: labels) + expect(pdf_result).to be_a_valid_pdf + end + + it 'successfully sets labels for page ranges' do + pdf_result = client.set_page_label(file: pdf_file, labels: [{ start_page: 0, end_page: 1, label: 'Intro' }]) + expect(pdf_result).to be_a_valid_pdf + end + end + + describe '#json_import' do + let(:json_data) { File.read('spec/fixtures/annotations.json') } + let(:json_file) { 'spec/fixtures/annotations.json' } + + it 'successfully imports JSON data to a PDF' do + pdf_result = client.json_import(file: pdf_file, json_data: json_data) + expect(pdf_result).to be_a_valid_pdf + end + + it 'accepts JSON data as a file' do + pdf_result = client.json_import(file: pdf_file, json_file: json_file) + expect(pdf_result).to be_a_valid_pdf + end + end + + describe '#xfdf_import' do + let(:xfdf_file) { 'spec/fixtures/annotations.xfdf' } + + it 'successfully imports XFDF data to a PDF' do + pdf_result = client.xfdf_import(file: pdf_file, xfdf_file: xfdf_file) + expect(pdf_result).to be_a_valid_pdf + end + + it 'accepts File objects for XFDF data' do + File.open(xfdf_file, 'rb') do |xfdf| + pdf_result = client.xfdf_import(file: pdf_file, xfdf_file: xfdf) + expect(pdf_result).to be_a_valid_pdf + end + end + end + describe 'remote URL support' do let(:remote_pdf_url) { 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' }