11from __future__ import annotations
22
3- from typing import Annotated , Any
3+ import re
4+ from collections .abc import Callable
5+ from pathlib import PurePath
6+ from typing import Annotated , Any , Literal
47
58from pydantic import (
9+ AfterValidator ,
10+ AliasChoices ,
611 BaseModel ,
712 BeforeValidator ,
13+ ConfigDict ,
814 Field ,
915 HttpUrl ,
16+ PlainSerializer ,
17+ model_validator ,
1018)
1119
20+ from . import PdfRestFile
21+ from .public import PdfRestFileID
22+
1223
1324def _ensure_list (value : Any ) -> Any :
1425 if value is None :
@@ -22,10 +33,248 @@ def _list_of_strings(value: list[Any]) -> list[str]:
2233 return [str (e ) for e in value ]
2334
2435
36+ def _validate_output_prefix (value : str | None ) -> str | None :
37+ """Validate output prefix to prevent directory traversal and reserved or unsafe names."""
38+ if value is None :
39+ return None
40+ if "/" in value or "\\ " in value or ":" in value :
41+ msg = "The output prefix must not contain a directory separator."
42+ raise ValueError (msg )
43+ if value .startswith ("." ):
44+ msg = "The output prefix must not start with a `.`."
45+ raise ValueError (msg )
46+ if ".." in value :
47+ msg = "The output prefix must not contain `..`."
48+ raise ValueError (msg )
49+ basename = PurePath (value ).name
50+ if value != basename :
51+ msg = "The output prefix must not include directory components."
52+ raise ValueError (msg )
53+ if basename in {"profile.json" , "metadata.json" }:
54+ msg = "The output prefix is a reserved name."
55+ raise ValueError (msg )
56+ special_chars_pattern = r"[`!@#$%^&*()+=\[\]{};':\"\\|,<>?~]"
57+ matches = re .findall (special_chars_pattern , value )
58+ if matches :
59+ violations : list [str ] = []
60+ for char in matches :
61+ if char not in violations :
62+ violations .append (char )
63+ msg = (
64+ "The output prefix must not contain special characters: "
65+ + ", " .join (repr (char ) for char in violations )
66+ + "."
67+ )
68+ raise ValueError (msg )
69+ return value
70+
71+
72+ def _require_positive_page (
73+ text : str , * , description : str , require_page_word : bool = False
74+ ) -> str :
75+ if not text .isdigit () or int (text ) < 1 :
76+ message = (
77+ f"{ description } must be a page number greater than or equal to 1."
78+ if require_page_word
79+ else f"{ description } must be greater than or equal to 1."
80+ )
81+ raise ValueError (message )
82+ return text
83+
84+
85+ def _validate_page_range_entry (value : str ) -> str :
86+ """Normalize and validate a single page range entry."""
87+ if not isinstance (value , str ):
88+ msg = "Each page range entry must be a string."
89+ raise TypeError (msg )
90+ entry = value .strip ()
91+ if entry == "" :
92+ msg = "Each page range entry must be a non-empty string."
93+ raise ValueError (msg )
94+ if entry == "last" :
95+ return entry
96+ if entry .isdigit ():
97+ return _require_positive_page (entry , description = "Page numbers" )
98+ if "-" in entry :
99+ start_raw , end_raw = (part .strip () for part in entry .split ("-" , maxsplit = 1 ))
100+ start = _require_positive_page (
101+ start_raw , description = "Page range start" , require_page_word = True
102+ )
103+ if end_raw == "last" :
104+ return f"{ start } -last"
105+ end = _require_positive_page (
106+ end_raw , description = "Page range end" , require_page_word = True
107+ )
108+ if int (end ) < int (start ):
109+ msg = "Page range end must be greater than or equal to the start."
110+ raise ValueError (msg )
111+ return f"{ start } -{ end } "
112+ msg = "Page range entries must be positive integers, 'last', or a range like '1-3' or '6-last'."
113+ raise ValueError (msg )
114+
115+
116+ def _split_comma_list (value : Any ) -> Any :
117+ if isinstance (value , str ):
118+ return value .split ("," )
119+ if isinstance (value , list ):
120+ return value
121+ msg = "Must be a comma separated string or a list of strings."
122+ raise ValueError (msg )
123+
124+
125+ def _pdfrest_file_to_id (value : Any ) -> Any :
126+ if isinstance (value , PdfRestFile ):
127+ return value .id
128+ return value
129+
130+
131+ def _serialize_as_first_file_id (value : list [PdfRestFile ]) -> str :
132+ return str (value [0 ].id )
133+
134+
135+ def _serialize_as_comma_separated_string (value : list [Any ] | None ) -> str | None :
136+ if value is None :
137+ return None
138+ return "," .join (value )
139+
140+
141+ PageRangeEntry = Annotated [str , AfterValidator (_validate_page_range_entry )]
142+
143+
144+ def _allowed_mime_types (
145+ allowed_mime_types : str , * more_allowed_mime_types : str , error_msg : str | None
146+ ) -> Callable [[Any ], Any ]:
147+ combined_allowed_mime_types = [allowed_mime_types , * more_allowed_mime_types ]
148+
149+ def allowed_mime_types_validator (
150+ value : PdfRestFile | list [PdfRestFile ],
151+ ) -> PdfRestFile | list [PdfRestFile ]:
152+ if isinstance (value , list ):
153+ for item in value :
154+ allowed_mime_types_validator (item )
155+ return value
156+ if value .type not in combined_allowed_mime_types :
157+ msg = error_msg or f"The file type must be one of: { allowed_mime_types } "
158+ raise ValueError (msg )
159+ return value
160+
161+ return allowed_mime_types_validator
162+
163+
164+ def _int_to_string (value : Any ) -> Any :
165+ if isinstance (value , int ):
166+ return str (value )
167+ if isinstance (value , list ):
168+ return [_int_to_string (item ) for item in value ]
169+ return value
170+
171+
25172class UploadURLs (BaseModel ):
26173 url : Annotated [
27174 list [HttpUrl ] | HttpUrl ,
28175 Field (min_length = 1 ),
29176 BeforeValidator (_list_of_strings ),
30177 BeforeValidator (_ensure_list ),
31178 ]
179+
180+
181+ class ConvertToGraphic (BaseModel ):
182+ files : Annotated [
183+ list [PdfRestFile ],
184+ Field (
185+ min_length = 1 ,
186+ max_length = 1 ,
187+ validation_alias = AliasChoices ("file" , "files" ),
188+ serialization_alias = "id" ,
189+ ),
190+ AfterValidator (
191+ _allowed_mime_types ("application/pdf" , error_msg = "Must be a PDF file" )
192+ ),
193+ BeforeValidator (_ensure_list ),
194+ PlainSerializer (_serialize_as_first_file_id ),
195+ ]
196+ output_prefix : Annotated [
197+ str | None ,
198+ Field (serialization_alias = "output" , min_length = 1 , default = None ),
199+ AfterValidator (_validate_output_prefix ),
200+ ]
201+ page_range : Annotated [
202+ list [PageRangeEntry ] | None ,
203+ Field (serialization_alias = "pages" , min_length = 1 , default = None ),
204+ BeforeValidator (_ensure_list ),
205+ BeforeValidator (_split_comma_list ),
206+ BeforeValidator (_int_to_string ),
207+ PlainSerializer (_serialize_as_comma_separated_string ),
208+ ]
209+ resolution : Annotated [int , Field (ge = 12 , le = 2400 , default = 300 )]
210+ color_model : Annotated [Literal ["rgb" , "rgba" , "gray" ], Field (default = "rgb" )]
211+ smoothing : Annotated [
212+ list [Literal ["none" , "all" , "text" , "line" , "image" ]],
213+ Field (default = "none" ),
214+ BeforeValidator (_ensure_list ),
215+ BeforeValidator (_split_comma_list ),
216+ PlainSerializer (_serialize_as_comma_separated_string ),
217+ ]
218+
219+
220+ class PdfRestRawUploadedFile (BaseModel ):
221+ """The response sent by /upload is a list of these. /unzip returns files like this
222+ with outputUrl"""
223+
224+ name : Annotated [str , Field (description = "The name of the file" )]
225+ id : Annotated [PdfRestFileID , Field (description = "The id of the file" )]
226+ output_url : Annotated [
227+ str | None ,
228+ Field (description = "The url of the unzipped file" , alias = "outputUrl" ),
229+ BeforeValidator (_ensure_list ),
230+ ] = None
231+
232+
233+ class PdfRestRawFileResponse (BaseModel ):
234+ """The raw response from file-based pdfRest calls."""
235+
236+ # Allow all extra fields to be stored and serialized
237+ # See: https://docs.pydantic.dev/latest/concepts/models/#extra-fields
238+ model_config = ConfigDict (extra = "allow" )
239+
240+ input_id : Annotated [
241+ list [PdfRestFileID ],
242+ Field (alias = "inputId" , description = "The id of the input file" ),
243+ BeforeValidator (_ensure_list ),
244+ ]
245+ output_urls : Annotated [
246+ list [HttpUrl ] | None ,
247+ Field (alias = "outputUrl" , description = "The url of the file" ),
248+ BeforeValidator (_ensure_list ),
249+ ] = None
250+ output_ids : Annotated [
251+ list [PdfRestFileID ] | None ,
252+ Field (alias = "outputId" , description = "The id of the file" ),
253+ BeforeValidator (_ensure_list ),
254+ ] = None
255+ files : Annotated [
256+ list [PdfRestRawUploadedFile ] | None ,
257+ Field (description = "The file(s) returned from the /unzip operation" ),
258+ BeforeValidator (_ensure_list ),
259+ ] = None
260+ warning : Annotated [
261+ str | None ,
262+ Field (
263+ description = "A warning that was generated during the pdfRest operation" ,
264+ ),
265+ ] = None
266+
267+ @model_validator (mode = "after" )
268+ def _check_output_id_or_files (self ) -> Any :
269+ if self .output_ids is None and self .files is None :
270+ msg = "output_id or files must be specified"
271+ raise ValueError (msg )
272+ return self
273+
274+ @property
275+ def ids (self ) -> list [PdfRestFileID ] | None :
276+ if self .output_ids is not None :
277+ return self .output_ids
278+ if self .files is not None :
279+ return [f .id for f in self .files ]
280+ return None
0 commit comments