@@ -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
3131fn 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