papers-cli 2.15.3__tar.gz → 2.15.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {papers_cli-2.15.3 → papers_cli-2.15.5}/PKG-INFO +1 -1
  2. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/_version.py +3 -3
  3. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/extract.py +163 -28
  4. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/PKG-INFO +1 -1
  5. {papers_cli-2.15.3 → papers_cli-2.15.5}/.gitattributes +0 -0
  6. {papers_cli-2.15.3 → papers_cli-2.15.5}/.github/FUNDING.yml +0 -0
  7. {papers_cli-2.15.3 → papers_cli-2.15.5}/.github/workflows/pypi.yml +0 -0
  8. {papers_cli-2.15.3 → papers_cli-2.15.5}/.github/workflows/tests.yml +0 -0
  9. {papers_cli-2.15.3 → papers_cli-2.15.5}/.gitignore +0 -0
  10. {papers_cli-2.15.3 → papers_cli-2.15.5}/LICENSE +0 -0
  11. {papers_cli-2.15.3 → papers_cli-2.15.5}/README.md +0 -0
  12. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/__init__.py +0 -0
  13. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/__main__.py +0 -0
  14. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/bib.py +0 -0
  15. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/config.py +0 -0
  16. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/duplicate.py +0 -0
  17. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/encoding.py +0 -0
  18. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/filename.py +0 -0
  19. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/latexenc.py +0 -0
  20. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers/utils.py +0 -0
  21. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/SOURCES.txt +0 -0
  22. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/dependency_links.txt +0 -0
  23. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/entry_points.txt +0 -0
  24. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/requires.txt +0 -0
  25. {papers_cli-2.15.3 → papers_cli-2.15.5}/papers_cli.egg-info/top_level.txt +0 -0
  26. {papers_cli-2.15.3 → papers_cli-2.15.5}/pyproject.toml +0 -0
  27. {papers_cli-2.15.3 → papers_cli-2.15.5}/requirements.txt +0 -0
  28. {papers_cli-2.15.3 → papers_cli-2.15.5}/setup.cfg +0 -0
  29. {papers_cli-2.15.3 → papers_cli-2.15.5}/setup.py +0 -0
  30. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/__init__.py +0 -0
  31. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/common.py +0 -0
  32. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/config.py +0 -0
  33. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/download.py +0 -0
  34. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/downloadedpapers/.gitignore +0 -0
  35. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/downloadedpapers/bg-8-515-2011.txt +0 -0
  36. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_add.py +0 -0
  37. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_biblio.py +0 -0
  38. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_duplicates.py +0 -0
  39. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_encoding.py +0 -0
  40. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_extract.py +0 -0
  41. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_filecheck.py +0 -0
  42. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_install.py +0 -0
  43. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_list.py +0 -0
  44. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_misc.py +0 -0
  45. {papers_cli-2.15.3 → papers_cli-2.15.5}/tests/test_undo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: papers-cli
3
- Version: 2.15.3
3
+ Version: 2.15.5
4
4
  Summary: utilities to keep your PDF library organized
5
5
  Author-email: Mahé Perrette <mahe.perrette@gmail.com>
6
6
  License: MIT
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '2.15.3'
32
- __version_tuple__ = version_tuple = (2, 15, 3)
31
+ __version__ = version = '2.15.5'
32
+ __version_tuple__ = version_tuple = (2, 15, 5)
33
33
 
34
- __commit_id__ = commit_id = 'gccc7b54c0'
34
+ __commit_id__ = commit_id = 'gbad573f0d'
@@ -110,49 +110,178 @@ def readpdf_image(pdf, first=None, last=None):
110
110
 
111
111
  return txt
112
112
 
113
- REGEXP = re.compile(r'[doi,doi.org/][\s\.\:]{0,2}(10\.\d{4}[\d\:\.\-\/a-z]+)[A-Z\s,\n]')
113
+ # CrossRef DOI standard: /^10.\d{4,9}/[-._;()/:A-Z0-9]+$/i
114
+ REGEXP = re.compile(r'[doi,doi.org/][\s\.\:]{0,2}(10\.\d{4,9}/[-._;()/:a-z0-9]+)')
114
115
  ARXIV = re.compile(r'arxiv:\s*(\d{4}\.\d{4,5})')
115
116
 
116
- def parse_doi(txt):
117
- # based on: https://doeidoei.wordpress.com/2009/10/22/regular-expression-to-match-a-doi-digital-object-identifier/
118
- # doi = r'[doi|DOI][\s\.\:]{0,2}(10\.\d{4}[\d\:\.\-\/a-z]+)[A-Z\s]'
117
+ def _parse_doi_from_metadata_string(metadata):
118
+ """Extract DOI from XMP metadata string."""
119
+ patterns = [
120
+ r'<prism:doi>(10\.\d{4,9}/[-._;()/:a-z0-9]+)</prism:doi>',
121
+ r'<dc:identifier>doi:(10\.\d{4,9}/[-._;()/:a-z0-9]+)</dc:identifier>',
122
+ r'<pdfx:doi>(10\.\d{4,9}/[-._;()/:a-z0-9]+)</pdfx:doi>',
123
+ r'<crossmark:DOI>(10\.\d{4,9}/[-._;()/:a-z0-9]+)</crossmark:DOI>',
124
+ ]
125
+ for pattern in patterns:
126
+ match = re.search(pattern, metadata, re.IGNORECASE)
127
+ if match:
128
+ return match.group(1)
129
+ return None
119
130
 
120
- # maybe try that? (need to convert to python-regex)
121
- # https://www.crossref.org/blog/dois-and-matching-regular-expressions/
122
- # a. /^10.\d{4,9}/[-._;()/:A-Z0-9]+$/i
123
- # b. /^10.1002/[^\s]+$/i
124
- # c. /^10.\d{4}/\d+-\d+X?(\d+)\d+<[\d\w]+:[\d\w]*>\d+.\d+.\w+;\d$/i
125
- # d. /^10.1021/\w\w\d++$/i
126
- # e. /^10.1207/[\w\d]+\&\d+_\d+$/i
131
+ def parse_doi_from_pdf_metadata_poppler(pdf_path):
132
+ """Extract DOI from PDF metadata using pdfinfo (poppler-utils)."""
133
+ try:
134
+ result = sp.run(['pdfinfo', '-meta', pdf_path],
135
+ capture_output=True, text=True, timeout=5)
136
+ if result.returncode == 0:
137
+ return _parse_doi_from_metadata_string(result.stdout)
138
+ except Exception:
139
+ pass
140
+ return None
127
141
 
128
- matches = REGEXP.findall(' '+txt.lower()+' ')
142
+ def parse_doi_from_pdf_metadata_fitz(pdf_path):
143
+ """Extract DOI from PDF metadata using PyMuPDF/fitz."""
144
+ import fitz
129
145
 
130
- if not matches:
146
+ with fitz.open(pdf_path) as doc:
147
+ metadata = doc.metadata
131
148
 
132
- # try arxiv pattern
133
- match = ARXIV.search(txt.lower())
134
- if match:
135
- arxiv_id = match.group(1)
136
- matches = [ f"10.48550/arXiv.{arxiv_id}" ]
149
+ # Try direct metadata fields first
150
+ if metadata:
151
+ # Check common metadata fields
152
+ for key in ['subject', 'keywords', 'title']:
153
+ value = metadata.get(key, '')
154
+ if value and '10.' in value:
155
+ # Try to extract DOI from the field
156
+ doi_match = re.search(r'10\.\d{4,9}/[-._;()/:a-z0-9]+', value, re.IGNORECASE)
157
+ if doi_match:
158
+ return doi_match.group(0)
137
159
 
138
- else:
139
- raise DOIParsingError('parse_doi::no matches')
160
+ # Fall back to XMP metadata
161
+ xmp = doc.get_xml_metadata() if hasattr(doc, 'get_xml_metadata') else doc.xref_get_key(-1, "Metadata")
162
+ if xmp:
163
+ xmp_str = xmp if isinstance(xmp, str) else xmp.decode('utf-8', errors='ignore')
164
+ return _parse_doi_from_metadata_string(xmp_str)
165
+
166
+
167
+ def parse_doi_from_pdf_metadata(pdf_path):
168
+ """Extract DOI from PDF metadata (tries fitz, falls back to poppler)."""
169
+ # Try fitz first (no subprocess overhead)
170
+
171
+ try:
172
+ return parse_doi_from_pdf_metadata_fitz(pdf_path)
173
+ except ImportError:
174
+ # Fall back to poppler-utils
175
+ return parse_doi_from_pdf_metadata_poppler(pdf_path)
176
+
177
+ def parse_doi(txt):
178
+ # Remove invisible Unicode characters and normalize dashes and slashes
179
+ # U+200B: zero-width space, U+00AD: soft hyphen, U+2013: en-dash, U+2014: em-dash
180
+ # \x02: STX (Start of Text) sometimes used instead of slash in PDFs
181
+ txt_clean = txt.replace('\u200b', '').replace('\xad', '').replace('\u2013', '-').replace('\u2014', '-').replace('\x02', '/')
182
+ txt_lower = txt_clean.lower()
140
183
 
141
- match = matches[0]
184
+ # Handle DOI split at "10.\n<registrant>" (e.g., "10.\n1073/pnas.123")
185
+ # Requires 4-9 digits (valid registrant) followed by slash
186
+ txt_lower = re.sub(r'(10\.)\s*\n\s*(\d{4,9}/)', r'\1\2', txt_lower)
142
187
 
143
- # clean expression
144
- doi = match.replace('\n','').strip('.')
188
+ # Special case: PNAS DOIs may be split at "10.1073/pnas. \n<digits>"
189
+ # This is a very specific pattern that's safe to join
190
+ txt_lower = re.sub(r'(10\.1073/pnas\.)\s*\n\s*(\d)', r'\1\2', txt_lower)
145
191
 
146
- if doi.lower().endswith('.received'):
147
- doi = doi[:-len('.received')]
192
+ # Handle DOIs split at dash-newline-alphanumeric (common in many publishers)
193
+ # e.g., "10.1175/jcli-d-21-\n0636.1", "10.1175/JCLI-\nD-17-0112.s1"
194
+ txt_lower = re.sub(r'(10\.\d{4,9}/[-._;()/:a-z0-9]+-)\s*\n\s*([a-z0-9])', r'\1\2', txt_lower)
148
195
 
149
- # quality check
196
+ # Don't join across newlines generally - it causes more problems than it solves
197
+ # The newline naturally stops incorrect matches from extending into following text
198
+ matches = REGEXP.findall(txt_lower)
199
+
200
+ if not matches:
201
+ # Try arxiv pattern
202
+ match = ARXIV.search(txt_lower)
203
+ if match:
204
+ return f"10.48550/arXiv.{match.group(1)}"
205
+
206
+ raise DOIParsingError('parse_doi::no matches')
207
+
208
+ # Start with first match as default
209
+ doi = matches[0]
210
+
211
+ # Prefer DOIs that appear in full URL context (e.g., www.pnas.org/cgi/doi/10.1073/...)
212
+ # These are more likely to be the article's own DOI rather than citations
213
+ if len(matches) > 1:
214
+ url_patterns = [
215
+ r'www\.[a-z]+\.org/[a-z/]+/(10\.\d{4,9}/[-._;()/:a-z0-9]+)',
216
+ r'doi\.org/(10\.\d{4,9}/[-._;()/:a-z0-9]+)',
217
+ r'dx\.doi\.org/(10\.\d{4,9}/[-._;()/:a-z0-9]+)',
218
+ ]
219
+ for pattern in url_patterns:
220
+ url_matches = re.findall(pattern, txt_lower)
221
+ # If any of our DOI matches appear in a URL, prefer the first one
222
+ for doi_match in matches:
223
+ if doi_match in url_matches:
224
+ doi = doi_match
225
+ break
226
+ if doi in url_matches: # Found a URL match, stop searching patterns
227
+ break
228
+
229
+ # If multiple matches and first is supplemental, prefer non-supplemental with same base
230
+ # e.g., prefer "10.1175/jcli-d-16-0271.1" over "10.1175/jcli-d-16-0271.s1"
231
+ if len(matches) > 1 and doi == matches[0]:
232
+ # Check if first match is supplemental (.s<digit>)
233
+ if re.search(r'\.s\d+\.?$', matches[0]):
234
+ # Look for non-supplemental version with same base
235
+ base = re.sub(r'\.s\d+\.?$', '', matches[0])
236
+ for m in matches[1:]:
237
+ if m.startswith(base) and not re.search(r'\.s\d+\.?$', m):
238
+ doi = m
239
+ break
240
+
241
+ # Remove non-DOI suffixes: known publisher paths and common junk
242
+ # Strategy: if we find these patterns, remove them AND everything after
243
+ suffixes = [
244
+ # Publisher-specific paths
245
+ '/-/dcsupplemental',
246
+ '/-/dc',
247
+ # Common file extension
248
+ '.pdf',
249
+ # Document status markers that clearly shouldn't be in DOI
250
+ 'preprint',
251
+ 'received',
252
+ 'published',
253
+ 'edited',
254
+ 'advance',
255
+ 'full',
256
+ 'abstract',
257
+ '-supplement',
258
+ 'supplement',
259
+ ]
260
+
261
+ doi_lower = doi.lower()
262
+ for suffix in suffixes:
263
+ pos = doi_lower.find(suffix)
264
+ if pos > 0: # Found suffix (not at start)
265
+ # For word-only suffixes, ensure they follow a valid DOI character
266
+ if suffix.isalpha():
267
+ prev_char = doi[pos-1]
268
+ # Accept if preceded by alphanumeric, dash, underscore, slash, or dot
269
+ if prev_char.isalnum() or prev_char in '.-_/':
270
+ doi = doi[:pos]
271
+ break
272
+ else:
273
+ # Non-word suffixes (paths, extensions) - just cut
274
+ doi = doi[:pos]
275
+ break
276
+
277
+ # Clean up trailing periods and colons
278
+ doi = doi.rstrip('.:')
279
+
280
+ # Quality check
150
281
  if len(doi) <= 8:
151
282
  raise DOIParsingError('failed to extract doi: '+doi)
152
283
 
153
284
  return doi
154
-
155
-
156
285
  def isvaliddoi(doi):
157
286
  try:
158
287
  doi2 = parse_doi('doi:'+doi)
@@ -178,6 +307,12 @@ def pdfhead(pdf, maxpages=10, minwords=200, image=False):
178
307
 
179
308
 
180
309
  def extract_pdf_doi(pdf, image=False):
310
+ # Try PDF metadata first (fast and reliable for many publishers)
311
+ metadata_doi = parse_doi_from_pdf_metadata(pdf)
312
+ if metadata_doi:
313
+ return metadata_doi
314
+
315
+ # Fall back to text extraction if metadata doesn't have DOI
181
316
  return parse_doi(pdfhead(pdf, image=image))
182
317
 
183
318
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: papers-cli
3
- Version: 2.15.3
3
+ Version: 2.15.5
4
4
  Summary: utilities to keep your PDF library organized
5
5
  Author-email: Mahé Perrette <mahe.perrette@gmail.com>
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes