bibdeskparser 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,467 @@
1
+ """BibDesk's TeX <-> Unicode string conversion.
2
+
3
+ BibDesk converts between TeX markup and Unicode when reading and writing
4
+ `.bib` files:
5
+
6
+ - `detexify`: TeX -> Unicode (BibDesk applies this on *read*, for display)
7
+ - `texify`: Unicode -> TeX (BibDesk applies this on *write*, to the `.bib`
8
+ file)
9
+
10
+ This module replicates that conversion. The tables are transcribed
11
+ byte-for-byte from BibDesk's `CharacterConversion.plist`, and the
12
+ functions mirror the algorithms in BibDesk's `BDSKConverter.m`, so the
13
+ conversion matches BibDesk exactly: for a `.bib` file written by BibDesk,
14
+ `detexify` (read) followed by `texify` (write) reproduces the original
15
+ file. Characters that BibDesk cannot express in TeX (e.g., `π`, `ℏ`, `₂`)
16
+ pass through untouched in both directions.
17
+ """
18
+
19
+ import unicodedata
20
+
21
+ # All members whose name does not start with an underscore must be listed
22
+ # either in __all__ or in __private__
23
+ __all__ = []
24
+ __private__ = ["detexify", "texify", "skip_texify"]
25
+
26
+
27
+ # Roman to TeX + One-Way Conversions from `CharacterConversion.plist`
28
+ # (used by texify; the One-Way entries convert forward only)
29
+ _TEXIFY = {
30
+ "¡": "{!'}",
31
+ "§": "{\\S}",
32
+ "©": "{\\copyright}",
33
+ "®": "{\\textregistered}",
34
+ "Á": "{\\'A}",
35
+ "Â": "{\\^A}",
36
+ "Ã": "{\\~A}",
37
+ "Ä": '{\\"A}',
38
+ "Å": "{\\AA}",
39
+ "Æ": "{\\AE}",
40
+ "Ç": "{\\c C}",
41
+ "É": "{\\'E}",
42
+ "Ê": "{\\^E}",
43
+ "Ë": '{\\"E}',
44
+ "Ì": "{\\`I}",
45
+ "Í": "{\\'I}",
46
+ "Î": "{\\^I}",
47
+ "Ï": '{\\"I}',
48
+ "Ó": "{\\'O}",
49
+ "Ô": "{\\^O}",
50
+ "Ö": '{\\"O}',
51
+ "Ø": "{\\O}",
52
+ "Ú": "{\\'U}",
53
+ "Û": "{\\^U}",
54
+ "Ü": '{\\"U}',
55
+ "ß": "{\\ss}",
56
+ "à": "{\\`a}",
57
+ "á": "{\\'a}",
58
+ "â": "{\\^a}",
59
+ "ã": "{\\~a}",
60
+ "ä": '{\\"a}',
61
+ "å": "{\\aa}",
62
+ "æ": "{\\ae}",
63
+ "ç": "{\\c c}",
64
+ "è": "{\\`e}",
65
+ "é": "{\\'e}",
66
+ "ê": "{\\^e}",
67
+ "ë": '{\\"e}',
68
+ "ì": "{\\`\\i}",
69
+ "í": "{\\'\\i}",
70
+ "î": "{\\^\\i}",
71
+ "ï": '{\\"\\i}',
72
+ "ñ": "{\\~n}",
73
+ "ò": "{\\`o}",
74
+ "ó": "{\\'o}",
75
+ "ô": "{\\^o}",
76
+ "õ": "{\\~o}",
77
+ "ö": '{\\"o}',
78
+ "ø": "{\\o}",
79
+ "ù": "{\\`u}",
80
+ "ú": "{\\'u}",
81
+ "û": "{\\^u}",
82
+ "ü": '{\\"u}',
83
+ "ÿ": '{\\"y}',
84
+ "Ā": "{\\=A}",
85
+ "ā": "{\\=a}",
86
+ "ć": "{\\'c}",
87
+ "Č": "{\\v C}",
88
+ "č": "{\\v c}",
89
+ "ě": "{\\v e}",
90
+ "Ī": "{\\=I}",
91
+ "ī": "{\\=\\i}",
92
+ "Ł": "{\\L}",
93
+ "ł": "{\\l}",
94
+ "ő": "{\\H o}",
95
+ "Œ": "{\\OE}",
96
+ "œ": "{\\oe}",
97
+ "ş": "{\\c s}",
98
+ "š": "{\\v s}",
99
+ "Ū": "{\\=U}",
100
+ "ū": "{\\=u}",
101
+ "Ÿ": '{\\"Y}',
102
+ "Ž": "{\\v Z}",
103
+ "ž": "{\\v z}",
104
+ "Ḍ": "{\\d D}",
105
+ "ḍ": "{\\d d}",
106
+ "Ḥ": "{\\d H}",
107
+ "ḥ": "{\\d h}",
108
+ "Ṣ": "{\\d S}",
109
+ "ṣ": "{\\d s}",
110
+ "Ṭ": "{\\d T}",
111
+ "ṭ": "{\\d t}",
112
+ "Ẓ": "{\\d Z}",
113
+ "ẓ": "{\\d z}",
114
+ "…": "{\\ldots}",
115
+ "™": "{\\texttrademark}",
116
+ "\u00a0": " ",
117
+ "\u00ad": "-",
118
+ "°": "$\\,^{\\circ}$",
119
+ "±": "$\\pm$",
120
+ "–": "--",
121
+ "—": "---",
122
+ "‘": "`",
123
+ "’": "'",
124
+ "‛": "`",
125
+ "“": "``",
126
+ "”": "''",
127
+ "‟": "``",
128
+ "•": "*",
129
+ "ff": "ff",
130
+ "fi": "fi",
131
+ "fl": "fl",
132
+ "ffi": "ffi",
133
+ "ffl": "ffl",
134
+ }
135
+
136
+ # TeX to Roman from `CharacterConversion.plist` (used by detexify)
137
+ _DETEXIFY = {
138
+ "{!'}": "¡",
139
+ '{\\"A}': "Ä",
140
+ '{\\"E}': "Ë",
141
+ '{\\"I}': "Ï",
142
+ '{\\"O}': "Ö",
143
+ '{\\"U}': "Ü",
144
+ '{\\"Y}': "Ÿ",
145
+ '{\\"\\i}': "ï",
146
+ '{\\"a}': "ä",
147
+ '{\\"e}': "ë",
148
+ '{\\"o}': "ö",
149
+ '{\\"u}': "ü",
150
+ '{\\"y}': "ÿ",
151
+ "{\\'A}": "Á",
152
+ "{\\'E}": "É",
153
+ "{\\'I}": "Í",
154
+ "{\\'O}": "Ó",
155
+ "{\\'U}": "Ú",
156
+ "{\\'\\i}": "í",
157
+ "{\\'a}": "á",
158
+ "{\\'c}": "ć",
159
+ "{\\'e}": "é",
160
+ "{\\'o}": "ó",
161
+ "{\\'u}": "ú",
162
+ "{\\=A}": "Ā",
163
+ "{\\=I}": "Ī",
164
+ "{\\=U}": "Ū",
165
+ "{\\=\\i}": "ī",
166
+ "{\\=a}": "ā",
167
+ "{\\=u}": "ū",
168
+ "{\\AA}": "Å",
169
+ "{\\AE}": "Æ",
170
+ "{\\H o}": "ő",
171
+ "{\\OE}": "Œ",
172
+ "{\\O}": "Ø",
173
+ "{\\S}": "§",
174
+ "{\\^A}": "Â",
175
+ "{\\^E}": "Ê",
176
+ "{\\^I}": "Î",
177
+ "{\\^O}": "Ô",
178
+ "{\\^U}": "Û",
179
+ "{\\^\\i}": "î",
180
+ "{\\^a}": "â",
181
+ "{\\^e}": "ê",
182
+ "{\\^o}": "ô",
183
+ "{\\^u}": "û",
184
+ "{\\`I}": "Ì",
185
+ "{\\`\\i}": "ì",
186
+ "{\\`a}": "à",
187
+ "{\\`e}": "è",
188
+ "{\\`o}": "ò",
189
+ "{\\`u}": "ù",
190
+ "{\\aa}": "å",
191
+ "{\\ae}": "æ",
192
+ "{\\c C}": "Ç",
193
+ "{\\c c}": "ç",
194
+ "{\\c s}": "ş",
195
+ "{\\cc}": "ç",
196
+ "{\\copyright}": "©",
197
+ "{\\d D}": "Ḍ",
198
+ "{\\d H}": "Ḥ",
199
+ "{\\d S}": "Ṣ",
200
+ "{\\d T}": "Ṭ",
201
+ "{\\d Z}": "Ẓ",
202
+ "{\\d d}": "ḍ",
203
+ "{\\d h}": "ḥ",
204
+ "{\\d s}": "ṣ",
205
+ "{\\d t}": "ṭ",
206
+ "{\\d z}": "ẓ",
207
+ "{\\ldots}": "…",
208
+ "{\\L}": "Ł",
209
+ "{\\l}": "ł",
210
+ "{\\oe}": "œ",
211
+ "{\\o}": "ø",
212
+ "{\\ss}": "ß",
213
+ "{\\textregistered}": "®",
214
+ "{\\texttrademark}": "™",
215
+ "{\\v C}": "Č",
216
+ "{\\v Z}": "Ž",
217
+ "{\\v c}": "č",
218
+ "{\\v e}": "ě",
219
+ "{\\v s}": "š",
220
+ "{\\v z}": "ž",
221
+ "{\\~A}": "Ã",
222
+ "{\\~a}": "ã",
223
+ "{\\~n}": "ñ",
224
+ "{\\~o}": "õ",
225
+ }
226
+
227
+ # accent letter/symbol -> combining mark (detexify accent algorithm)
228
+ _DETEX_ACCENTS = {
229
+ '"': "\u0308", # combining diaeresis
230
+ "'": "\u0301", # combining acute accent
231
+ ".": "\u0307", # combining dot above
232
+ "=": "\u0304", # combining macron
233
+ "H": "\u030b", # combining double acute accent
234
+ "^": "\u0302", # combining circumflex accent
235
+ "`": "\u0300", # combining grave accent
236
+ "b": "\u0331", # combining macron below
237
+ "c": "\u0327", # combining cedilla
238
+ "d": "\u0323", # combining dot below
239
+ "k": "\u0328", # combining ogonek
240
+ "r": "\u030a", # combining ring above
241
+ "u": "\u0306", # combining breve
242
+ "v": "\u030c", # combining caron
243
+ "~": "\u0303", # combining tilde
244
+ }
245
+
246
+ # combining mark -> accent letter/symbol, with a trailing space
247
+ # for letter accents (texify accent algorithm)
248
+ _TEX_ACCENTS = {
249
+ "\u0300": "`", # combining grave accent
250
+ "\u0301": "'", # combining acute accent
251
+ "\u0302": "^", # combining circumflex accent
252
+ "\u0303": "~", # combining tilde
253
+ "\u0304": "=", # combining macron
254
+ "\u0306": "u ", # combining breve
255
+ "\u0307": ".", # combining dot above
256
+ "\u0308": '"', # combining diaeresis
257
+ "\u030a": "r ", # combining ring above
258
+ "\u030b": "H ", # combining double acute accent
259
+ "\u030c": "v ", # combining caron
260
+ "\u0331": "b ", # combining macron below
261
+ "\u0323": "d ", # combining dot below
262
+ "\u0327": "c ", # combining cedilla
263
+ "\u0328": "k ", # combining ogonek
264
+ }
265
+
266
+ # BibDesk's finalCharacterSet: the codepoints that texify will
267
+ # attempt to convert
268
+ # fmt: off
269
+ _FINAL = frozenset([
270
+ 160, 161, 167, 169, 173, 174, 176, 177, 192, 193, 194, 195, 196, 197,
271
+ 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 209, 210, 211, 212,
272
+ 213, 214, 216, 217, 218, 219, 220, 221, 223, 224, 225, 226, 227, 228,
273
+ 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 241, 242, 243,
274
+ 244, 245, 246, 248, 249, 250, 251, 252, 253, 255, 256, 257, 258, 259,
275
+ 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 274, 275,
276
+ 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289,
277
+ 290, 291, 292, 293, 296, 297, 298, 299, 300, 301, 302, 303, 304, 308,
278
+ 309, 310, 311, 313, 314, 315, 316, 317, 318, 321, 322, 323, 324, 325,
279
+ 326, 327, 328, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342,
280
+ 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356,
281
+ 357, 360, 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 371, 372,
282
+ 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 416, 417, 431, 432,
283
+ 461, 462, 463, 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474,
284
+ 475, 476, 478, 479, 480, 481, 482, 483, 486, 487, 488, 489, 490, 491,
285
+ 492, 493, 494, 495, 496, 500, 501, 504, 505, 506, 507, 508, 509, 510,
286
+ 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524,
287
+ 525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538,
288
+ 539, 542, 543, 550, 551, 552, 553, 554, 555, 556, 557, 558, 559, 560,
289
+ 561, 562, 563, 7680, 7681, 7682, 7683, 7684, 7685, 7686, 7687, 7688,
290
+ 7689, 7690, 7691, 7692, 7693, 7694, 7695, 7696, 7697, 7698, 7699,
291
+ 7700, 7701, 7702, 7703, 7704, 7705, 7706, 7707, 7708, 7709, 7710,
292
+ 7711, 7712, 7713, 7714, 7715, 7716, 7717, 7718, 7719, 7720, 7721,
293
+ 7722, 7723, 7724, 7725, 7726, 7727, 7728, 7729, 7730, 7731, 7732,
294
+ 7733, 7734, 7735, 7736, 7737, 7738, 7739, 7740, 7741, 7742, 7743,
295
+ 7744, 7745, 7746, 7747, 7748, 7749, 7750, 7751, 7752, 7753, 7754,
296
+ 7755, 7756, 7757, 7758, 7759, 7760, 7761, 7762, 7763, 7764, 7765,
297
+ 7766, 7767, 7768, 7769, 7770, 7771, 7772, 7773, 7774, 7775, 7776,
298
+ 7777, 7778, 7779, 7780, 7781, 7782, 7783, 7784, 7785, 7786, 7787,
299
+ 7788, 7789, 7790, 7791, 7792, 7793, 7794, 7795, 7796, 7797, 7798,
300
+ 7799, 7800, 7801, 7802, 7803, 7804, 7805, 7806, 7807, 7808, 7809,
301
+ 7810, 7811, 7812, 7813, 7814, 7815, 7816, 7817, 7818, 7819, 7820,
302
+ 7821, 7822, 7823, 7824, 7825, 7826, 7827, 7828, 7829, 7830, 7831,
303
+ 7832, 7833, 7835, 7840, 7841, 7842, 7843, 7844, 7845, 7846, 7847,
304
+ 7848, 7849, 7850, 7851, 7852, 7853, 7854, 7855, 7856, 7857, 7858,
305
+ 7859, 7860, 7861, 7862, 7863, 7864, 7865, 7866, 7867, 7868, 7869,
306
+ 7870, 7871, 7872, 7873, 7874, 7875, 7876, 7877, 7878, 7879, 7880,
307
+ 7881, 7882, 7883, 7884, 7885, 7886, 7887, 7888, 7889, 7890, 7891,
308
+ 7892, 7893, 7894, 7895, 7896, 7897, 7898, 7899, 7900, 7901, 7902,
309
+ 7903, 7904, 7905, 7906, 7907, 7908, 7909, 7910, 7911, 7912, 7913,
310
+ 7914, 7915, 7916, 7917, 7918, 7919, 7920, 7921, 7922, 7923, 7924,
311
+ 7925, 7926, 7927, 7928, 7929, 8211, 8212, 8216, 8217, 8219, 8220,
312
+ 8221, 8223, 8226, 8230, 8482, 64256, 64257, 64258, 64259, 64260
313
+ ])
314
+ # fmt: on
315
+
316
+ # base letters that may carry a TeX accent
317
+ _BASE = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
318
+
319
+
320
+ def _tex_accent_to_char(tmp):
321
+ r"""Convert a `{\<accent><letter>}` token to a composed character.
322
+
323
+ Mirrors `convertTeXStringToComposedCharacter()` in BibDesk's
324
+ `BDSKConverter.m`. Returns the single composed Unicode character for
325
+ `tmp`, or `None` if `tmp` is not a valid accent token.
326
+ """
327
+ if len(tmp) < 4 or tmp[0] != "{" or tmp[1] != "\\":
328
+ return None
329
+ accent_ch = tmp[2]
330
+ accent = _DETEX_ACCENTS.get(accent_ch)
331
+ if accent is None:
332
+ return None
333
+ ch = tmp[3]
334
+ if accent_ch.isalpha() and ch not in (" ", "\\"):
335
+ # letter accents (e.g. {\v S}) must be followed by space/backslash
336
+ return None
337
+ letter_start = 4 if ch == " " else 3
338
+ end = tmp.find("}", letter_start)
339
+ if end == -1:
340
+ return None
341
+ character = tmp[letter_start:end]
342
+ if character in ("\\i", "\\j"):
343
+ character = character[1:] # dotless i/j -> plain i/j
344
+ if len(character) != 1:
345
+ return None
346
+ composed = unicodedata.normalize("NFC", character + accent)
347
+ return composed if len(composed) == 1 else None
348
+
349
+
350
+ def detexify(s):
351
+ r"""Convert TeX markup in `s` to Unicode.
352
+
353
+ ```python
354
+ >>> from bibdeskparser.texmap import detexify
355
+ >>> detexify('Universit{\\"a}t T{\\"u}bingen')
356
+ 'Universität Tübingen'
357
+
358
+ ```
359
+
360
+ Mirrors `copyStringByDeTeXifyingString:` in BibDesk's
361
+ `BDSKConverter.m`; BibDesk applies this conversion to every field
362
+ value when reading a `.bib` file.
363
+
364
+ # Arguments
365
+
366
+ * `s`: The string to convert.
367
+
368
+ Only `{\...}` sequences are converted; the function is a no-op
369
+ unless `s` contains `{\`.
370
+ """
371
+ if not s or "{\\" not in s:
372
+ return s
373
+ out = s
374
+ i = out.find("{\\")
375
+ converted = False
376
+ while i != -1:
377
+ close = out.find("}", i + 2)
378
+ if close == -1:
379
+ break
380
+ tmp = out[i : close + 1]
381
+ repl = _DETEXIFY.get(tmp) or _tex_accent_to_char(tmp)
382
+ if repl is not None:
383
+ out = out[:i] + repl + out[close + 1 :]
384
+ converted = True
385
+ # advance one char past the current '{' (matches BibDesk's
386
+ # search reset)
387
+ i = out.find("{\\", i + 1)
388
+ return out if converted else s
389
+
390
+
391
+ def _char_to_tex_accent(ch):
392
+ r"""Convert a composed character to a `{\<accent><letter>}` token.
393
+
394
+ Mirrors `convertComposedCharacterToTeX()` in BibDesk's
395
+ `BDSKConverter.m`. Returns the TeX form of the single character `ch`
396
+ (or `ch` itself for a plain base letter), or `None` if `ch` cannot be
397
+ expressed as a TeX accent.
398
+ """
399
+ d = unicodedata.normalize("NFD", ch)
400
+ if len(d) == 0 or d[0] not in _BASE:
401
+ return None
402
+ if len(d) == 1:
403
+ return ch # base letter, nothing to do
404
+ if len(d) > 2 or d[1] not in _TEX_ACCENTS:
405
+ return None
406
+ accent = _TEX_ACCENTS[d[1]]
407
+ character = d[0]
408
+ if character in ("i", "j") and accent not in ("c ", "d ", "b ", "k "):
409
+ character = "\\" + character # dotless \i / \j
410
+ return "{\\" + accent + character + "}"
411
+
412
+
413
+ def texify(s):
414
+ r"""Convert Unicode characters in `s` to TeX markup.
415
+
416
+ ```python
417
+ >>> from bibdeskparser.texmap import texify
418
+ >>> texify("Universität Tübingen")
419
+ 'Universit{\\"a}t T{\\"u}bingen'
420
+
421
+ ```
422
+
423
+ Mirrors `copyStringByTeXifyingString:` in BibDesk's
424
+ `BDSKConverter.m`; BibDesk applies this conversion to every field
425
+ value when writing a `.bib` file.
426
+
427
+ # Arguments
428
+
429
+ * `s`: The string to convert.
430
+
431
+ The string is first NFC-normalized; only characters whose codepoint
432
+ is in BibDesk's "final character set" are converted, and any other
433
+ character (e.g., `π`, `ℏ`, `₂`) passes through untouched.
434
+ """
435
+ if not s:
436
+ return s
437
+ s = unicodedata.normalize("NFC", s)
438
+ out = []
439
+ changed = False
440
+ for ch in s:
441
+ if ord(ch) in _FINAL:
442
+ repl = _TEXIFY.get(ch)
443
+ if repl is None:
444
+ repl = _char_to_tex_accent(ch)
445
+ if repl is not None and repl != ch:
446
+ out.append(repl)
447
+ changed = True
448
+ continue
449
+ out.append(ch)
450
+ return "".join(out) if changed else s
451
+
452
+
453
+ def skip_texify(key):
454
+ r"""Whether the field `key` is excluded from TeX conversion.
455
+
456
+ # Arguments
457
+
458
+ * `key`: The field key, e.g. `"url"` or `"bdsk-file-1"`. Matching is
459
+ case-insensitive.
460
+
461
+ Returns `True` if the (lowercased) `key` contains `"url"` or starts
462
+ with `"bdsk-file"`. BibDesk does not TeXify URL fields, and
463
+ `bdsk-file` fields hold base64 data. Both are pure ASCII without
464
+ `{\` sequences, so this only matters as a safety guard.
465
+ """
466
+ k = key.lower()
467
+ return "url" in k or k.startswith("bdsk-file")
@@ -0,0 +1,234 @@
1
+ r"""Serialization of a `bibtexparser` library in BibDesk's file format.
2
+
3
+ BibDesk lays out a `.bib` file as the header comment, the `@string`
4
+ definitions, the entries, and finally the group `@comment` blocks.
5
+ Blocks are separated by one blank line, except for two blank lines
6
+ before the `@string` section and before the first entry. Entry fields
7
+ are written one per line, indented with a tab, with the closing brace
8
+ fused onto the last field line.
9
+
10
+ `render_library` serializes a parsed library back to that exact
11
+ format: for an unmodified library, the output is byte-identical to the
12
+ original file.
13
+
14
+ ```python
15
+ >>> import bibtexparser
16
+ >>> from bibdeskparser.middleware import parse_stack
17
+ >>> from bibdeskparser.writer import render_library
18
+ >>> source = (
19
+ ... '@article{key1,\n'
20
+ ... '\tauthor = {Gr{\\"u}n, Anna},\n'
21
+ ... '\tyear = {2026}}\n'
22
+ ... )
23
+ >>> library = bibtexparser.parse_string(source, parse_stack=parse_stack())
24
+ >>> library.entries[0]["author"] # Unicode model ...
25
+ '{Grün, Anna}'
26
+ >>> render_library(library) == source # ... but byte-exact output
27
+ True
28
+
29
+ ```
30
+ """
31
+
32
+ from bibtexparser.model import (
33
+ Entry,
34
+ ExplicitComment,
35
+ ImplicitComment,
36
+ ParsingFailedBlock,
37
+ String,
38
+ )
39
+
40
+ from .bdskfile import BibDeskFile
41
+ from .header import restore_trailing_space
42
+ from .middleware import TeXifyMiddleware
43
+
44
+ __all__ = []
45
+
46
+ # All members whose name does not start with an underscore must be listed
47
+ # either in __all__ or in __private__
48
+ __private__ = [
49
+ "render_library",
50
+ "serialize_block",
51
+ "bibdesk_field_order",
52
+ "separator",
53
+ ]
54
+
55
+
56
+ #: First line of the header comment in files written by BibDesk.
57
+ _BIBDESK_HEADER_PREFIX = (
58
+ "%% This BibTeX bibliography file was created using BibDesk"
59
+ )
60
+
61
+
62
+ def serialize_block(block):
63
+ r"""Serialize a single block in BibDesk's exact format.
64
+
65
+ ```python
66
+ serialize_block(block)
67
+ ```
68
+
69
+ Returns the string representation (without trailing newline) of a
70
+ `bibtexparser` block:
71
+
72
+ * `ImplicitComment`: the comment text, verbatim. If the comment is
73
+ a BibDesk header, the trailing space that the parser rstripped
74
+ from the last line is restored.
75
+ * `String`: `@string{key = value}`.
76
+ * `ExplicitComment`: `@comment{...}`, with the comment body
77
+ written verbatim.
78
+ * `Entry`: the entry type and key, then one tab-indented line per
79
+ field, with the closing brace directly after the last field
80
+ value. {any}`bibdeskparser.bdskfile.BibDeskFile` values are
81
+ serialized via their
82
+ {any}`~bibdeskparser.bdskfile.BibDeskFile.to_field_value` method.
83
+ * `ParsingFailedBlock` (e.g., a `DuplicateBlockKeyBlock` for an
84
+ entry with a duplicate key): the block's `raw` source slice,
85
+ verbatim, so that files with duplicate keys still round-trip.
86
+
87
+ Raises {any}`TypeError` for any other block type.
88
+
89
+ ```python
90
+ >>> from bibtexparser.model import Entry, Field, String
91
+ >>> from bibdeskparser.writer import serialize_block
92
+ >>> print(serialize_block(String(key="jpb", value="{J. Phys. B}")))
93
+ @string{jpb = {J. Phys. B}}
94
+ >>> entry = Entry(
95
+ ... entry_type="article",
96
+ ... key="key1",
97
+ ... fields=[
98
+ ... Field(key="author", value="{Goerz}"),
99
+ ... Field(key="year", value="{2026}"),
100
+ ... ],
101
+ ... )
102
+ >>> serialize_block(entry)
103
+ '@article{key1,\n\tauthor = {Goerz},\n\tyear = {2026}}'
104
+
105
+ ```
106
+ """
107
+ if isinstance(block, ImplicitComment):
108
+ text = block.comment
109
+ if text.startswith(_BIBDESK_HEADER_PREFIX):
110
+ text = restore_trailing_space(text)
111
+ return text
112
+ if isinstance(block, String):
113
+ return f"@string{{{block.key} = {block.value}}}"
114
+ if isinstance(block, ExplicitComment):
115
+ return f"@comment{{{block.comment}}}"
116
+ if isinstance(block, Entry):
117
+ if not block.fields:
118
+ return f"@{block.entry_type}{{{block.key}}}"
119
+ lines = [f"@{block.entry_type}{{{block.key},"]
120
+ last = len(block.fields) - 1
121
+ for i, field in enumerate(block.fields):
122
+ value = field.value
123
+ if isinstance(value, BibDeskFile):
124
+ value = value.to_field_value()
125
+ tail = "}" if i == last else ","
126
+ lines.append(f"\t{field.key} = {value}{tail}")
127
+ return "\n".join(lines)
128
+ if isinstance(block, ParsingFailedBlock):
129
+ return block.raw
130
+ raise TypeError(f"Unhandled block type: {type(block).__name__}")
131
+
132
+
133
+ def _effective_type(block):
134
+ """The block type to use for separator purposes.
135
+
136
+ A `ParsingFailedBlock` counts as the block it wraps
137
+ (`ignore_error_block`), or as an `Entry` if it wraps nothing, since
138
+ failed blocks are almost always broken entries (e.g., duplicate
139
+ keys)."""
140
+ if isinstance(block, ParsingFailedBlock):
141
+ inner = block.ignore_error_block
142
+ return type(inner) if inner is not None else Entry
143
+ return type(block)
144
+
145
+
146
+ def separator(prev_block, cur_block):
147
+ """The blank-line separator BibDesk puts between two blocks.
148
+
149
+ ```python
150
+ separator(prev_block, cur_block)
151
+ ```
152
+
153
+ Returns `"\\n\\n\\n"` (two blank lines) between the header comment
154
+ and the first `@string`, and between the last `@string` and the
155
+ first entry, i.e., when `prev_block` is an `ImplicitComment` and
156
+ `cur_block` is a `String`, or when `prev_block` is a `String` and
157
+ `cur_block` is an `Entry`. Returns `"\\n\\n"` (one blank line) for
158
+ every other transition. A `ParsingFailedBlock` counts as the block
159
+ it wraps (usually an `Entry` with a duplicate key).
160
+ """
161
+ prev_type = _effective_type(prev_block)
162
+ cur_type = _effective_type(cur_block)
163
+ double = (
164
+ issubclass(prev_type, ImplicitComment) and issubclass(cur_type, String)
165
+ ) or (issubclass(prev_type, String) and issubclass(cur_type, Entry))
166
+ return "\n\n\n" if double else "\n\n"
167
+
168
+
169
+ def render_library(library):
170
+ """Serialize a library to the exact text BibDesk would write.
171
+
172
+ ```python
173
+ render_library(library)
174
+ ```
175
+
176
+ Returns the full text of the `.bib` file for `library`: all blocks
177
+ serialized with `serialize_block`, joined with `separator`, with a
178
+ final newline. Before serializing, the library is passed through
179
+ `TeXifyMiddleware` on a copy (`allow_inplace_modification=False`),
180
+ so Unicode field values are written as TeX and the caller's
181
+ library is left untouched.
182
+ """
183
+ library = TeXifyMiddleware(allow_inplace_modification=False).transform(
184
+ library
185
+ )
186
+ pieces = []
187
+ prev = None
188
+ for block in library.blocks:
189
+ if prev is not None:
190
+ pieces.append(separator(prev, block))
191
+ pieces.append(serialize_block(block))
192
+ prev = block
193
+ pieces.append("\n")
194
+ return "".join(pieces)
195
+
196
+
197
+ def bibdesk_field_order(fields):
198
+ """Sort entry fields the way BibDesk does.
199
+
200
+ ```python
201
+ bibdesk_field_order(fields)
202
+ ```
203
+
204
+ Returns the list of `bibtexparser` `Field` objects in `fields`
205
+ sorted case-insensitively alphabetically by key, except that all
206
+ `bdsk-*` fields (file attachments, URLs) come after all other
207
+ fields (each group sorted alphabetically). The sort is stable and
208
+ purely lexical, so, e.g., `bdsk-file-10` sorts *before*
209
+ `bdsk-file-2` (`"1" < "2"`), matching the field order in files
210
+ written by BibDesk.
211
+
212
+ This helper is not used by `render_library` (which writes fields
213
+ in their existing order); it is intended for normalizing the field
214
+ order of newly created or modified entries.
215
+
216
+ ```python
217
+ >>> from bibtexparser.model import Field
218
+ >>> from bibdeskparser.writer import bibdesk_field_order
219
+ >>> fields = [
220
+ ... Field(key="bdsk-url-1", value="{http://example.org}"),
221
+ ... Field(key="year", value="{2026}"),
222
+ ... Field(key="author", value="{Goerz, Michael}"),
223
+ ... ]
224
+ >>> [field.key for field in bibdesk_field_order(fields)]
225
+ ['author', 'year', 'bdsk-url-1']
226
+
227
+ ```
228
+ """
229
+
230
+ def sort_key(field):
231
+ key = field.key.lower()
232
+ return (key.startswith("bdsk-"), key)
233
+
234
+ return sorted(fields, key=sort_key)