Skip to content

Commit eb08ee2

Browse files
author
Jonathan D.A. Jewell
committed
Add fill_blocks fixture harness and native core test path
1 parent d63d221 commit eb08ee2

2 files changed

Lines changed: 283 additions & 32 deletions

File tree

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"build:js:dev": "deno run -A npm:webpack --mode=development",
1010
"build": "deno task build:res && deno task build:js",
1111
"dev": "deno task build:res && deno task build:js:dev && deno run -A npm:web-ext run --source-dir dist",
12-
"lint": "deno task build:res"
12+
"lint": "deno task build:res",
13+
"test:core-fill": "cargo test --manifest-path rust/pdftool_core/Cargo.toml fill_blocks_"
1314
}
1415
}

rust/pdftool_core/src/lib.rs

Lines changed: 281 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,25 @@ struct CoreErrorPayload {
2222
context: Option<String>,
2323
}
2424

25-
type CoreResult<T> = Result<T, JsValue>;
25+
type CoreResult<T> = Result<T, CoreErrorPayload>;
2626

27-
fn core_error(code: &'static str, message: impl Into<String>) -> JsValue {
27+
fn core_error(code: &'static str, message: impl Into<String>) -> CoreErrorPayload {
2828
core_error_with_context(code, message, None)
2929
}
3030

3131
fn core_error_with_context(
3232
code: &'static str,
3333
message: impl Into<String>,
3434
context: Option<String>,
35-
) -> JsValue {
36-
let payload = CoreErrorPayload {
35+
) -> CoreErrorPayload {
36+
CoreErrorPayload {
3737
code,
3838
message: message.into(),
3939
context,
40-
};
40+
}
41+
}
42+
43+
fn core_error_to_js(payload: CoreErrorPayload) -> JsValue {
4144
serde_wasm_bindgen::to_value(&payload)
4245
.unwrap_or_else(|_| JsValue::from_str(&format!("{}: {}", payload.code, payload.message)))
4346
}
@@ -521,8 +524,7 @@ fn apply_field_value(doc: &mut Document, descriptor: &FieldDescriptor, value: &s
521524
}
522525
}
523526

524-
#[wasm_bindgen]
525-
pub fn detect_blocks(pdf_data: &[u8]) -> Result<JsValue, JsValue> {
527+
fn detect_blocks_impl(pdf_data: &[u8]) -> CoreResult<Vec<Block>> {
526528
if pdf_data.is_empty() {
527529
return Err(core_error("BW_PDF_EMPTY", "empty PDF payload"));
528530
}
@@ -594,36 +596,14 @@ pub fn detect_blocks(pdf_data: &[u8]) -> Result<JsValue, JsValue> {
594596
}
595597
}
596598

597-
serde_wasm_bindgen::to_value(&blocks)
598-
.map_err(|err| core_error_with_context("BW_SERIALIZATION_ERROR", err.to_string(), Some("detect_blocks".into())))
599+
Ok(blocks)
599600
}
600601

601-
#[wasm_bindgen]
602-
pub fn fill_blocks(
603-
pdf_data: &[u8],
604-
blocks: JsValue,
605-
fields: JsValue,
606-
) -> Result<js_sys::Uint8Array, JsValue> {
602+
fn fill_blocks_impl(pdf_data: &[u8], field_values: HashMap<String, String>) -> CoreResult<Vec<u8>> {
607603
if pdf_data.is_empty() {
608604
return Err(core_error("BW_PDF_EMPTY", "empty PDF payload"));
609605
}
610606

611-
let _requested_blocks: Vec<Block> = serde_wasm_bindgen::from_value(blocks).map_err(|err| {
612-
core_error_with_context(
613-
"BW_BLOCKS_PAYLOAD_INVALID",
614-
err.to_string(),
615-
Some("fill_blocks blocks argument".into()),
616-
)
617-
})?;
618-
619-
let field_values: HashMap<String, String> = serde_wasm_bindgen::from_value(fields).map_err(|err| {
620-
core_error_with_context(
621-
"BW_FIELDS_PAYLOAD_INVALID",
622-
err.to_string(),
623-
Some("fill_blocks fields argument".into()),
624-
)
625-
})?;
626-
627607
let mut doc = Document::load_mem(pdf_data)
628608
.map_err(|err| core_error_with_context("BW_PDF_INVALID", err.to_string(), Some("Document::load_mem".into())))?;
629609

@@ -688,5 +668,275 @@ pub fn fill_blocks(
688668
doc.save_to(&mut output)
689669
.map_err(|err| core_error_with_context("BW_FILL_SAVE_FAILED", err.to_string(), Some("Document::save_to".into())))?;
690670

671+
Ok(output)
672+
}
673+
674+
#[wasm_bindgen]
675+
pub fn detect_blocks(pdf_data: &[u8]) -> Result<JsValue, JsValue> {
676+
let blocks = detect_blocks_impl(pdf_data).map_err(core_error_to_js)?;
677+
serde_wasm_bindgen::to_value(&blocks)
678+
.map_err(|err| core_error_to_js(core_error_with_context("BW_SERIALIZATION_ERROR", err.to_string(), Some("detect_blocks".into()))))
679+
}
680+
681+
#[wasm_bindgen]
682+
pub fn fill_blocks(
683+
pdf_data: &[u8],
684+
blocks: JsValue,
685+
fields: JsValue,
686+
) -> Result<js_sys::Uint8Array, JsValue> {
687+
let _requested_blocks: Vec<Block> = serde_wasm_bindgen::from_value(blocks).map_err(|err| {
688+
core_error_to_js(core_error_with_context(
689+
"BW_BLOCKS_PAYLOAD_INVALID",
690+
err.to_string(),
691+
Some("fill_blocks blocks argument".into()),
692+
))
693+
})?;
694+
695+
let field_values: HashMap<String, String> = serde_wasm_bindgen::from_value(fields).map_err(|err| {
696+
core_error_to_js(core_error_with_context(
697+
"BW_FIELDS_PAYLOAD_INVALID",
698+
err.to_string(),
699+
Some("fill_blocks fields argument".into()),
700+
))
701+
})?;
702+
let output = fill_blocks_impl(pdf_data, field_values).map_err(core_error_to_js)?;
691703
Ok(js_sys::Uint8Array::from(output.as_slice()))
692704
}
705+
706+
#[cfg(test)]
707+
mod tests {
708+
use super::*;
709+
use lopdf::{dictionary, Stream};
710+
711+
fn name(value: &str) -> Object {
712+
Object::Name(value.as_bytes().to_vec())
713+
}
714+
715+
fn rect(llx: i64, lly: i64, urx: i64, ury: i64) -> Object {
716+
Object::Array(vec![llx.into(), lly.into(), urx.into(), ury.into()])
717+
}
718+
719+
fn assert_error_code(result: CoreResult<Vec<u8>>, expected_code: &str) {
720+
let payload = result.expect_err("expected fill_blocks to fail");
721+
assert_eq!(payload.code, expected_code);
722+
assert!(!payload.message.is_empty(), "error message should not be empty");
723+
}
724+
725+
fn make_fixture_pdf() -> Vec<u8> {
726+
let mut doc = Document::with_version("1.7");
727+
728+
let pages_id = doc.new_object_id();
729+
let page_id = doc.new_object_id();
730+
let content_id = doc.new_object_id();
731+
let catalog_id = doc.new_object_id();
732+
let acroform_id = doc.new_object_id();
733+
734+
let text_field_id = doc.new_object_id();
735+
let checkbox_field_id = doc.new_object_id();
736+
let radio_parent_id = doc.new_object_id();
737+
let radio_widget_a_id = doc.new_object_id();
738+
let radio_widget_b_id = doc.new_object_id();
739+
740+
let content_stream = Stream::new(dictionary! {}, Vec::new());
741+
doc.objects
742+
.insert(content_id, Object::Stream(content_stream));
743+
744+
let text_field = dictionary! {
745+
"Type" => name("Annot"),
746+
"Subtype" => name("Widget"),
747+
"FT" => name("Tx"),
748+
"T" => Object::string_literal("Name"),
749+
"V" => Object::string_literal(""),
750+
"Rect" => rect(50, 700, 250, 724),
751+
"P" => Object::Reference(page_id),
752+
};
753+
doc.objects
754+
.insert(text_field_id, Object::Dictionary(text_field));
755+
756+
let checkbox_field = dictionary! {
757+
"Type" => name("Annot"),
758+
"Subtype" => name("Widget"),
759+
"FT" => name("Btn"),
760+
"T" => Object::string_literal("Consent"),
761+
"V" => name("Off"),
762+
"AS" => name("Off"),
763+
"Rect" => rect(50, 650, 70, 670),
764+
"P" => Object::Reference(page_id),
765+
"AP" => Object::Dictionary(dictionary! {
766+
"N" => Object::Dictionary(dictionary! {
767+
"Off" => Object::Null,
768+
"Yes" => Object::Null,
769+
})
770+
}),
771+
};
772+
doc.objects
773+
.insert(checkbox_field_id, Object::Dictionary(checkbox_field));
774+
775+
let radio_parent = dictionary! {
776+
"FT" => name("Btn"),
777+
"T" => Object::string_literal("Choice"),
778+
"Ff" => Object::Integer(32768),
779+
"Kids" => Object::Array(vec![
780+
Object::Reference(radio_widget_a_id),
781+
Object::Reference(radio_widget_b_id),
782+
]),
783+
};
784+
doc.objects
785+
.insert(radio_parent_id, Object::Dictionary(radio_parent));
786+
787+
let radio_widget_a = dictionary! {
788+
"Type" => name("Annot"),
789+
"Subtype" => name("Widget"),
790+
"Parent" => Object::Reference(radio_parent_id),
791+
"Rect" => rect(50, 600, 70, 620),
792+
"P" => Object::Reference(page_id),
793+
"AS" => name("Off"),
794+
"AP" => Object::Dictionary(dictionary! {
795+
"N" => Object::Dictionary(dictionary! {
796+
"Off" => Object::Null,
797+
"A" => Object::Null,
798+
})
799+
}),
800+
};
801+
doc.objects
802+
.insert(radio_widget_a_id, Object::Dictionary(radio_widget_a));
803+
804+
let radio_widget_b = dictionary! {
805+
"Type" => name("Annot"),
806+
"Subtype" => name("Widget"),
807+
"Parent" => Object::Reference(radio_parent_id),
808+
"Rect" => rect(120, 600, 140, 620),
809+
"P" => Object::Reference(page_id),
810+
"AS" => name("Off"),
811+
"AP" => Object::Dictionary(dictionary! {
812+
"N" => Object::Dictionary(dictionary! {
813+
"Off" => Object::Null,
814+
"B" => Object::Null,
815+
})
816+
}),
817+
};
818+
doc.objects
819+
.insert(radio_widget_b_id, Object::Dictionary(radio_widget_b));
820+
821+
let page = dictionary! {
822+
"Type" => name("Page"),
823+
"Parent" => Object::Reference(pages_id),
824+
"MediaBox" => rect(0, 0, 595, 842),
825+
"Resources" => Object::Dictionary(dictionary! {}),
826+
"Contents" => Object::Reference(content_id),
827+
"Annots" => Object::Array(vec![
828+
Object::Reference(text_field_id),
829+
Object::Reference(checkbox_field_id),
830+
Object::Reference(radio_widget_a_id),
831+
Object::Reference(radio_widget_b_id),
832+
]),
833+
};
834+
doc.objects.insert(page_id, Object::Dictionary(page));
835+
836+
let pages = dictionary! {
837+
"Type" => name("Pages"),
838+
"Kids" => Object::Array(vec![Object::Reference(page_id)]),
839+
"Count" => Object::Integer(1),
840+
};
841+
doc.objects.insert(pages_id, Object::Dictionary(pages));
842+
843+
let acroform = dictionary! {
844+
"Fields" => Object::Array(vec![
845+
Object::Reference(text_field_id),
846+
Object::Reference(checkbox_field_id),
847+
Object::Reference(radio_parent_id),
848+
]),
849+
};
850+
doc.objects
851+
.insert(acroform_id, Object::Dictionary(acroform));
852+
853+
let catalog = dictionary! {
854+
"Type" => name("Catalog"),
855+
"Pages" => Object::Reference(pages_id),
856+
"AcroForm" => Object::Reference(acroform_id),
857+
};
858+
doc.objects
859+
.insert(catalog_id, Object::Dictionary(catalog));
860+
doc.trailer.set(b"Root", Object::Reference(catalog_id));
861+
862+
let mut bytes = Vec::new();
863+
doc.save_to(&mut bytes).expect("serialize fixture pdf");
864+
bytes
865+
}
866+
867+
#[test]
868+
fn fill_blocks_errors_on_empty_pdf() {
869+
let fields = HashMap::<String, String>::new();
870+
let result = fill_blocks_impl(&[], fields);
871+
assert_error_code(result, "BW_PDF_EMPTY");
872+
}
873+
874+
#[test]
875+
fn fill_blocks_errors_on_invalid_pdf() {
876+
let fields = HashMap::<String, String>::new();
877+
let result = fill_blocks_impl(&[1, 2, 3, 4], fields);
878+
assert_error_code(result, "BW_PDF_INVALID");
879+
}
880+
881+
#[test]
882+
fn fill_blocks_errors_when_acroform_missing() {
883+
let mut doc = Document::with_version("1.7");
884+
let pages_id = doc.new_object_id();
885+
let catalog_id = doc.new_object_id();
886+
doc.objects.insert(
887+
pages_id,
888+
Object::Dictionary(dictionary! {
889+
"Type" => name("Pages"),
890+
"Kids" => Object::Array(vec![]),
891+
"Count" => Object::Integer(0),
892+
}),
893+
);
894+
doc.objects.insert(
895+
catalog_id,
896+
Object::Dictionary(dictionary! {
897+
"Type" => name("Catalog"),
898+
"Pages" => Object::Reference(pages_id),
899+
}),
900+
);
901+
doc.trailer.set(b"Root", Object::Reference(catalog_id));
902+
let mut input = Vec::new();
903+
doc.save_to(&mut input).expect("serialize minimal catalog");
904+
905+
let result = fill_blocks_impl(&input, HashMap::new());
906+
assert_error_code(result, "BW_FORM_MISSING_ACROFORM");
907+
}
908+
909+
#[test]
910+
fn fill_blocks_errors_when_no_field_names_match() {
911+
let pdf = make_fixture_pdf();
912+
let mut fields = HashMap::new();
913+
fields.insert("UnknownField".to_string(), "value".to_string());
914+
let result = fill_blocks_impl(&pdf, fields);
915+
assert_error_code(result, "BW_FILL_NO_MATCHING_FIELDS");
916+
}
917+
918+
#[test]
919+
fn fill_blocks_errors_on_invalid_radio_value() {
920+
let pdf = make_fixture_pdf();
921+
let mut fields = HashMap::new();
922+
fields.insert("Choice".to_string(), "not-a-state".to_string());
923+
let payload = fill_blocks_impl(&pdf, fields).expect_err("invalid radio value should fail");
924+
assert_eq!(payload.code, "BW_FILL_BUTTON_VALUE_INVALID");
925+
assert_eq!(payload.context.as_deref(), Some("Choice"));
926+
}
927+
928+
#[test]
929+
fn fill_blocks_updates_fixture_pdf() {
930+
let input_pdf = make_fixture_pdf();
931+
let mut fields = HashMap::new();
932+
fields.insert("Name".to_string(), "Ada Lovelace".to_string());
933+
fields.insert("Consent".to_string(), "true".to_string());
934+
fields.insert("Choice".to_string(), "A".to_string());
935+
936+
let output_bytes = fill_blocks_impl(&input_pdf, fields).expect("fixture fields should be fillable");
937+
938+
assert!(!output_bytes.is_empty(), "filled PDF payload should not be empty");
939+
assert_ne!(output_bytes, input_pdf, "filled PDF should differ from input bytes");
940+
Document::load_mem(&output_bytes).expect("filled payload should remain a valid PDF");
941+
}
942+
}

0 commit comments

Comments
 (0)