rxiv-maker 1.18.5__py3-none-any.whl → 1.19.1__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.
rxiv_maker/__version__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "1.18.5"
3
+ __version__ = "1.19.1"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from .arxiv import arxiv
4
4
  from .bibliography import bibliography
5
+ from .biorxiv import biorxiv
5
6
  from .build import build as pdf
6
7
  from .cache_management import cache_group as cache
7
8
  from .changelog import changelog
@@ -29,6 +30,7 @@ from .version import version
29
30
  __all__ = [
30
31
  "arxiv",
31
32
  "bibliography",
33
+ "biorxiv",
32
34
  "cache",
33
35
  "changelog",
34
36
  "config",
@@ -0,0 +1,51 @@
1
+ """bioRxiv submission package generation command."""
2
+
3
+ import rich_click as click
4
+
5
+ from ..framework.workflow_commands import BioRxivCommand
6
+
7
+
8
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
9
+ @click.argument("manuscript_path", type=click.Path(exists=True, file_okay=False), required=False)
10
+ @click.option("--output-dir", "-o", default="output", help="Output directory for generated files")
11
+ @click.option(
12
+ "--biorxiv-dir",
13
+ "-b",
14
+ help="Custom bioRxiv submission directory (default: output/biorxiv_submission)",
15
+ )
16
+ @click.option(
17
+ "--zip-filename",
18
+ "-z",
19
+ help="Custom ZIP filename (default: {manuscript}_biorxiv.zip)",
20
+ )
21
+ @click.option(
22
+ "--no-zip",
23
+ is_flag=True,
24
+ help="Don't create ZIP file (only create submission directory)",
25
+ )
26
+ @click.pass_context
27
+ def biorxiv(ctx, manuscript_path, output_dir, biorxiv_dir, zip_filename, no_zip):
28
+ r"""Generate bioRxiv submission package.
29
+
30
+ Creates a complete submission package including:
31
+ - bioRxiv author template (TSV file)
32
+ - Manuscript PDF
33
+ - Source files (TeX, figures, bibliography)
34
+ - ZIP file for upload
35
+
36
+ \b
37
+ Example:
38
+ rxiv biorxiv # Full package with ZIP
39
+ rxiv biorxiv --no-zip # Package without ZIP
40
+ rxiv biorxiv -b custom_dir # Custom submission directory
41
+ rxiv biorxiv -z my_submission.zip # Custom ZIP filename
42
+ """
43
+ command = BioRxivCommand()
44
+ return command.run(
45
+ ctx,
46
+ manuscript_path=manuscript_path,
47
+ output_dir=output_dir,
48
+ biorxiv_dir=biorxiv_dir,
49
+ zip_filename=zip_filename,
50
+ no_zip=no_zip,
51
+ )
@@ -162,6 +162,97 @@ class BaseCommand(ABC):
162
162
  if suggestion:
163
163
  self.console.print(f"💡 {suggestion}", style="yellow")
164
164
 
165
+ def _clear_output_directory(self) -> None:
166
+ """Clear and recreate the output directory.
167
+
168
+ Raises:
169
+ CommandExecutionError: If path_manager is not initialized
170
+ """
171
+ import shutil
172
+
173
+ if not self.path_manager:
174
+ raise CommandExecutionError("Path manager not initialized")
175
+
176
+ if self.path_manager.output_dir.exists():
177
+ shutil.rmtree(self.path_manager.output_dir)
178
+ self.path_manager.output_dir.mkdir(parents=True, exist_ok=True)
179
+
180
+ def _ensure_pdf_built(self, progress_task=None, quiet: bool = True) -> None:
181
+ """Ensure PDF is built, building it if necessary.
182
+
183
+ Args:
184
+ progress_task: Optional progress task to update
185
+ quiet: Whether to suppress build output
186
+
187
+ Raises:
188
+ CommandExecutionError: If path_manager is not initialized or build fails
189
+ """
190
+ from ...engines.operations.build_manager import BuildManager
191
+
192
+ if not self.path_manager:
193
+ raise CommandExecutionError("Path manager not initialized")
194
+
195
+ pdf_filename = f"{self.path_manager.manuscript_name}.pdf"
196
+ pdf_path = self.path_manager.output_dir / pdf_filename
197
+
198
+ if not pdf_path.exists():
199
+ if progress_task:
200
+ progress_task.update(description="Building PDF first...")
201
+
202
+ build_manager = BuildManager(
203
+ manuscript_path=str(self.path_manager.manuscript_path),
204
+ output_dir=str(self.path_manager.output_dir),
205
+ verbose=self.verbose,
206
+ quiet=quiet,
207
+ )
208
+
209
+ try:
210
+ success = build_manager.build()
211
+ if not success:
212
+ raise CommandExecutionError("PDF build failed")
213
+ except Exception as e:
214
+ raise CommandExecutionError(f"Failed to build PDF: {e}") from e
215
+
216
+ def _set_submission_defaults(
217
+ self,
218
+ submission_type: str,
219
+ submission_dir: Optional[str] = None,
220
+ zip_filename: Optional[str] = None,
221
+ ) -> tuple[str, str]:
222
+ """Set default paths for submission directories and ZIP files.
223
+
224
+ Args:
225
+ submission_type: Type of submission ("arxiv" or "biorxiv")
226
+ submission_dir: Custom submission directory path (optional)
227
+ zip_filename: Custom ZIP filename (optional)
228
+
229
+ Returns:
230
+ Tuple of (submission_dir, zip_filename) with defaults applied
231
+
232
+ Raises:
233
+ CommandExecutionError: If path_manager is not initialized
234
+ """
235
+ from pathlib import Path
236
+
237
+ if not self.path_manager:
238
+ raise CommandExecutionError("Path manager not initialized")
239
+
240
+ manuscript_output_dir = str(self.path_manager.output_dir)
241
+
242
+ # Set default submission directory
243
+ if submission_dir is None:
244
+ submission_dir = str(Path(manuscript_output_dir) / f"{submission_type}_submission")
245
+
246
+ # Set default ZIP filename
247
+ if zip_filename is None:
248
+ manuscript_name = self.path_manager.manuscript_name
249
+ if submission_type == "arxiv":
250
+ zip_filename = str(Path(manuscript_output_dir) / "for_arxiv.zip")
251
+ else:
252
+ zip_filename = str(Path(manuscript_output_dir) / f"{manuscript_name}_{submission_type}.zip")
253
+
254
+ return submission_dir, zip_filename
255
+
165
256
  @abstractmethod
166
257
  def execute_operation(self, **kwargs) -> Any:
167
258
  """Execute the main command operation.
@@ -366,46 +366,25 @@ class ArxivCommand(BaseCommand):
366
366
  no_zip: Don't create zip file
367
367
  """
368
368
  import sys
369
- from pathlib import Path
370
369
 
371
- from rxiv_maker.engines.operations.build_manager import BuildManager
372
370
  from rxiv_maker.engines.operations.prepare_arxiv import main as prepare_arxiv_main
373
371
 
374
- if self.path_manager is None:
375
- raise CommandExecutionError("Path manager not initialized")
376
-
372
+ # Set defaults using shared helper
373
+ arxiv_dir, zip_filename = self._set_submission_defaults("arxiv", arxiv_dir, zip_filename)
377
374
  manuscript_output_dir = str(self.path_manager.output_dir)
378
375
 
379
- # Set defaults using PathManager
380
- if arxiv_dir is None:
381
- arxiv_dir = str(Path(manuscript_output_dir) / "arxiv_submission")
382
- if zip_filename is None:
383
- zip_filename = str(Path(manuscript_output_dir) / "for_arxiv.zip")
384
-
385
376
  with self.create_progress() as progress:
386
- # Clear output directory first (similar to PDF command)
377
+ # Clear output directory using shared helper
387
378
  task = progress.add_task("Clearing output directory...", total=None)
388
- if self.path_manager.output_dir.exists():
389
- shutil.rmtree(self.path_manager.output_dir)
390
- self.path_manager.output_dir.mkdir(parents=True, exist_ok=True)
379
+ self._clear_output_directory()
391
380
 
392
- # First, ensure PDF is built
381
+ # Ensure PDF is built using shared helper
393
382
  progress.update(task, description="Checking PDF exists...")
394
- pdf_filename = f"{self.path_manager.manuscript_name}.pdf"
395
- pdf_path = self.path_manager.output_dir / pdf_filename
396
-
397
- if not pdf_path.exists():
398
- progress.update(task, description="Building PDF first...")
399
- build_manager = BuildManager(
400
- manuscript_path=str(self.path_manager.manuscript_path),
401
- output_dir=str(self.path_manager.output_dir),
402
- verbose=self.verbose,
403
- quiet=False,
404
- )
405
- success = build_manager.run()
406
- if not success:
407
- self.error_message("PDF build failed. Cannot prepare arXiv package.")
408
- raise CommandExecutionError("PDF build failed")
383
+ try:
384
+ self._ensure_pdf_built(progress_task=task, quiet=False)
385
+ except CommandExecutionError:
386
+ self.error_message("PDF build failed. Cannot prepare arXiv package.")
387
+ raise
409
388
 
410
389
  # Prepare arXiv package
411
390
  progress.update(task, description="Preparing arXiv package...")
@@ -524,6 +503,95 @@ class ArxivCommand(BaseCommand):
524
503
  return year, first_author
525
504
 
526
505
 
506
+ class BioRxivCommand(BaseCommand):
507
+ """BioRxiv command implementation for generating submission package."""
508
+
509
+ def execute_operation(
510
+ self,
511
+ output_dir: str = "output",
512
+ biorxiv_dir: Optional[str] = None,
513
+ zip_filename: Optional[str] = None,
514
+ no_zip: bool = False,
515
+ ) -> None:
516
+ """Execute bioRxiv submission package preparation.
517
+
518
+ Args:
519
+ output_dir: Output directory for generated files
520
+ biorxiv_dir: Custom bioRxiv submission directory path
521
+ zip_filename: Custom zip filename
522
+ no_zip: Don't create zip file
523
+ """
524
+ from pathlib import Path
525
+
526
+ from ...engines.operations.prepare_biorxiv import (
527
+ BioRxivAuthorError,
528
+ create_biorxiv_zip,
529
+ generate_biorxiv_author_tsv,
530
+ prepare_biorxiv_package,
531
+ )
532
+
533
+ # Set defaults using shared helper
534
+ biorxiv_dir, zip_filename = self._set_submission_defaults("biorxiv", biorxiv_dir, zip_filename)
535
+
536
+ with self.create_progress() as progress:
537
+ # Clear output directory using shared helper
538
+ task = progress.add_task("Clearing output directory...", total=None)
539
+ self._clear_output_directory()
540
+
541
+ # Ensure PDF is built using shared helper
542
+ progress.update(task, description="Checking PDF exists...")
543
+ self._ensure_pdf_built(progress_task=task, quiet=True)
544
+
545
+ # Generate bioRxiv author template TSV
546
+ progress.update(task, description="Generating bioRxiv author template...")
547
+ output_path = self.path_manager.output_dir
548
+ tsv_file = output_path / "biorxiv_authors.tsv"
549
+
550
+ try:
551
+ generate_biorxiv_author_tsv(
552
+ config_path=self.path_manager.get_config_file_path(),
553
+ output_path=tsv_file,
554
+ )
555
+ except BioRxivAuthorError as e:
556
+ progress.update(task, completed=True)
557
+ raise CommandExecutionError(f"Failed to generate bioRxiv template: {e}") from e
558
+
559
+ # Prepare bioRxiv submission package
560
+ progress.update(task, description="Preparing bioRxiv submission package...")
561
+ try:
562
+ biorxiv_path = prepare_biorxiv_package(
563
+ manuscript_path=self.path_manager.manuscript_path,
564
+ output_dir=self.path_manager.output_dir,
565
+ biorxiv_dir=Path(biorxiv_dir),
566
+ )
567
+ except Exception as e:
568
+ progress.update(task, completed=True)
569
+ raise CommandExecutionError(f"Failed to prepare bioRxiv package: {e}") from e
570
+
571
+ # Create ZIP file if requested
572
+ zip_path = None
573
+ if not no_zip:
574
+ progress.update(task, description="Creating ZIP package...")
575
+ try:
576
+ zip_path = create_biorxiv_zip(
577
+ biorxiv_path=biorxiv_path,
578
+ zip_filename=zip_filename,
579
+ manuscript_path=self.path_manager.manuscript_path,
580
+ )
581
+ except Exception as e:
582
+ progress.update(task, completed=True)
583
+ raise CommandExecutionError(f"Failed to create ZIP: {e}") from e
584
+
585
+ progress.update(task, completed=True)
586
+
587
+ # Show success message
588
+ self.console.print("\n[green]✅ bioRxiv submission package ready![/green]")
589
+ self.console.print(f" 📁 Package directory: {biorxiv_path}")
590
+ if zip_path:
591
+ self.console.print(f" 📦 ZIP file: {zip_path}")
592
+ self.console.print("\n📤 Upload to: https://submit.biorxiv.org/")
593
+
594
+
527
595
  class TrackChangesCommand(BaseCommand):
528
596
  """Track changes command implementation using the framework."""
529
597
 
rxiv_maker/cli/main.py CHANGED
@@ -60,7 +60,7 @@ click.rich_click.COMMAND_GROUPS = {
60
60
  },
61
61
  {
62
62
  "name": "Workflow Commands",
63
- "commands": ["get-rxiv-preprint", "arxiv", "track-changes", "setup"],
63
+ "commands": ["get-rxiv-preprint", "arxiv", "biorxiv", "track-changes", "setup"],
64
64
  },
65
65
  {
66
66
  "name": "Configuration",
@@ -252,6 +252,7 @@ main.add_command(commands.docx)
252
252
  main.add_command(commands.figures)
253
253
  main.add_command(commands.get_rxiv_preprint, name="get-rxiv-preprint")
254
254
  main.add_command(commands.arxiv)
255
+ main.add_command(commands.biorxiv)
255
256
  main.add_command(commands.init)
256
257
  main.add_command(commands.bibliography)
257
258
  main.add_command(commands.track_changes)
@@ -185,7 +185,7 @@ def convert_markdown_to_latex(
185
185
 
186
186
  # Post-processing: catch any remaining unconverted headers
187
187
  # This is a safety net in case some headers weren't converted properly
188
- content = re.sub(r"^### (.+)$", r"\\subsubsection{\1}", content, flags=re.MULTILINE)
188
+ content = re.sub(r"^### (.+)$", r"\\subsubsection{\1}\n\n", content, flags=re.MULTILINE)
189
189
 
190
190
  # Process supplementary note references BEFORE citations
191
191
  # (for both main and supplementary content)
@@ -457,26 +457,26 @@ def _process_tables_with_protection(
457
457
 
458
458
 
459
459
  def _convert_headers(content: LatexContent, is_supplementary: bool = False) -> LatexContent:
460
- """Convert markdown headers to LaTeX sections."""
460
+ """Convert markdown headers to LaTeX sections with proper spacing."""
461
461
  if is_supplementary:
462
462
  # For supplementary content, use \\section* for the first header
463
463
  # to avoid "Note 1:" prefix
464
464
  # First, find the first # header and replace it with \section*
465
- content = re.sub(r"^# (.+)$", r"\\section*{\1}", content, flags=re.MULTILINE, count=1)
465
+ content = re.sub(r"^# (.+)$", r"\\section*{\1}\n\n", content, flags=re.MULTILINE, count=1)
466
466
  # Then replace any remaining # headers with regular \section
467
- content = re.sub(r"^# (.+)$", r"\\section{\1}", content, flags=re.MULTILINE)
467
+ content = re.sub(r"^# (.+)$", r"\\section{\1}\n\n", content, flags=re.MULTILINE)
468
468
  else:
469
- content = re.sub(r"^# (.+)$", r"\\section{\1}", content, flags=re.MULTILINE)
469
+ content = re.sub(r"^# (.+)$", r"\\section{\1}\n\n", content, flags=re.MULTILINE)
470
470
 
471
- content = re.sub(r"^## (.+)$", r"\\subsection{\1}", content, flags=re.MULTILINE)
471
+ content = re.sub(r"^## (.+)$", r"\\subsection{\1}\n\n", content, flags=re.MULTILINE)
472
472
 
473
473
  # For supplementary content, ### headers are handled by the
474
474
  # supplementary note processor
475
475
  # For non-supplementary content, convert all ### headers normally
476
476
  if not is_supplementary:
477
- content = re.sub(r"^### (.+)$", r"\\subsubsection{\1}", content, flags=re.MULTILINE)
477
+ content = re.sub(r"^### (.+)$", r"\\subsubsection{\1}\n\n", content, flags=re.MULTILINE)
478
478
 
479
- content = re.sub(r"^#### (.+)$", r"\\paragraph{\1}", content, flags=re.MULTILINE)
479
+ content = re.sub(r"^#### (.+)$", r"\\paragraph{\1}\n\n", content, flags=re.MULTILINE)
480
480
  return content
481
481
 
482
482
 
@@ -0,0 +1,411 @@
1
+ """Prepare bioRxiv author submission template (TSV format).
2
+
3
+ This module generates a tab-separated values (TSV) file containing author information
4
+ formatted for bioRxiv submission system upload.
5
+ """
6
+
7
+ import csv
8
+ import html.entities
9
+ import logging
10
+ import shutil
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+ from ...core.managers.config_manager import ConfigManager
15
+ from ...utils.author_name_formatter import parse_author_name
16
+ from ...utils.email_encoder import decode_email
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def encode_html_entities(text: str) -> str:
22
+ """Convert Unicode characters to HTML entities for bioRxiv submission.
23
+
24
+ bioRxiv's TSV upload requires special characters to be encoded as HTML entities.
25
+ For example, "António" becomes "António", "Åbo" becomes "Åbo".
26
+ Characters without named entities use numeric references (e.g., "č" -> "č").
27
+
28
+ Args:
29
+ text: Text that may contain Unicode characters
30
+
31
+ Returns:
32
+ Text with Unicode characters converted to HTML entities
33
+ (e.g., "António" -> "António", "Åbo" -> "Åbo",
34
+ "Vaitkevičiūtė" -> "Vaitkevičiūtė")
35
+
36
+ Examples:
37
+ >>> encode_html_entities("António")
38
+ 'António'
39
+ >>> encode_html_entities("Åbo")
40
+ 'Åbo'
41
+ >>> encode_html_entities("José García")
42
+ 'José García'
43
+ >>> encode_html_entities("Vaitkevičiūtė")
44
+ 'Vaitkevičiūtė'
45
+ """
46
+ if not text:
47
+ return text
48
+
49
+ # Build reverse mapping: Unicode character -> HTML entity name
50
+ char_to_entity = {}
51
+ for entity_name, codepoint in html.entities.name2codepoint.items():
52
+ char = chr(codepoint)
53
+ # Skip basic ASCII characters and use named entities for special chars
54
+ if ord(char) > 127:
55
+ char_to_entity[char] = f"&{entity_name};"
56
+
57
+ # Convert each character to HTML entity (named or numeric)
58
+ result = []
59
+ for char in text:
60
+ char_code = ord(char)
61
+ if char_code <= 127:
62
+ # Basic ASCII - keep as-is
63
+ result.append(char)
64
+ elif char in char_to_entity:
65
+ # Has named entity - use it
66
+ result.append(char_to_entity[char])
67
+ else:
68
+ # No named entity - use numeric character reference
69
+ result.append(f"&#{char_code};")
70
+
71
+ return "".join(result)
72
+
73
+
74
+ class BioRxivAuthorError(Exception):
75
+ """Exception raised for bioRxiv author template generation errors."""
76
+
77
+ pass
78
+
79
+
80
+ def validate_author_data(authors: list[dict]) -> None:
81
+ """Validate author data for bioRxiv submission requirements.
82
+
83
+ Args:
84
+ authors: List of author dictionaries from config
85
+
86
+ Raises:
87
+ BioRxivAuthorError: If validation fails
88
+ """
89
+ if not authors:
90
+ raise BioRxivAuthorError("No authors found in configuration")
91
+
92
+ # Count corresponding authors
93
+ corresponding_count = sum(1 for author in authors if author.get("corresponding_author", False))
94
+
95
+ if corresponding_count == 0:
96
+ raise BioRxivAuthorError(
97
+ "No corresponding author found. "
98
+ "Exactly one author must be marked with 'corresponding_author: true' in 00_CONFIG.yml"
99
+ )
100
+
101
+ if corresponding_count > 1:
102
+ corresponding_names = [
103
+ author.get("name", "Unknown") for author in authors if author.get("corresponding_author", False)
104
+ ]
105
+ raise BioRxivAuthorError(
106
+ f"Multiple corresponding authors found: {', '.join(corresponding_names)}. "
107
+ "Only one author should be marked with 'corresponding_author: true' in 00_CONFIG.yml"
108
+ )
109
+
110
+ # Validate each author has a name
111
+ for i, author in enumerate(authors):
112
+ if not author.get("name"):
113
+ raise BioRxivAuthorError(f"Author at index {i} is missing the 'name' field")
114
+
115
+
116
+ def format_author_row(author_data: dict, affiliation_map: dict) -> list[str]:
117
+ """Format a single author's data as a bioRxiv TSV row.
118
+
119
+ Args:
120
+ author_data: Author dictionary with processed data
121
+ affiliation_map: Dictionary mapping affiliation shortnames to full data
122
+
123
+ Returns:
124
+ List of column values in bioRxiv order:
125
+ Email, Institution, First Name, Middle Name(s)/Initial(s), Last Name, Suffix,
126
+ Corresponding Author, Home Page URL, Collaborative Group/Consortium, ORCiD
127
+ """
128
+ # Email (decoded from email64)
129
+ email = author_data.get("email", "")
130
+
131
+ # Institution (first affiliation's full_name) - encode HTML entities for bioRxiv
132
+ institution = ""
133
+ affiliations = author_data.get("affiliations", [])
134
+ if affiliations and affiliations[0] in affiliation_map:
135
+ institution = encode_html_entities(affiliation_map[affiliations[0]].get("full_name", ""))
136
+
137
+ # Parse name into components and encode HTML entities for bioRxiv
138
+ name_str = author_data.get("name", "")
139
+ name_parts = parse_author_name(name_str)
140
+
141
+ first_name = encode_html_entities(name_parts.get("first", ""))
142
+ middle_name = encode_html_entities(name_parts.get("middle", ""))
143
+ last_name = encode_html_entities(name_parts.get("last", ""))
144
+ suffix = encode_html_entities(name_parts.get("suffix", ""))
145
+
146
+ # Corresponding author (any text for Yes, empty string for No)
147
+ corresponding = "Yes" if author_data.get("corresponding_author", False) else ""
148
+
149
+ # Home Page URL (empty - user preference)
150
+ home_page_url = ""
151
+
152
+ # Collaborative Group/Consortium (empty)
153
+ collaborative_group = ""
154
+
155
+ # ORCiD (if present)
156
+ orcid = author_data.get("orcid", "")
157
+
158
+ return [
159
+ email,
160
+ institution,
161
+ first_name,
162
+ middle_name,
163
+ last_name,
164
+ suffix,
165
+ corresponding,
166
+ home_page_url,
167
+ collaborative_group,
168
+ orcid,
169
+ ]
170
+
171
+
172
+ def generate_biorxiv_author_tsv(config_path: Path, output_path: Path) -> Path:
173
+ """Generate bioRxiv author submission template (TSV format).
174
+
175
+ Args:
176
+ config_path: Path to the manuscript 00_CONFIG.yml file
177
+ output_path: Path where the TSV file should be written
178
+
179
+ Returns:
180
+ Path to the generated TSV file
181
+
182
+ Raises:
183
+ BioRxivAuthorError: If author data is invalid or missing
184
+ FileNotFoundError: If config file doesn't exist
185
+ """
186
+ if not config_path.exists():
187
+ raise FileNotFoundError(f"Configuration file not found: {config_path}")
188
+
189
+ # Load manuscript configuration
190
+ config_manager = ConfigManager(config_path.parent)
191
+ config = config_manager.load_config(config_path)
192
+
193
+ # Extract authors and affiliations
194
+ authors = config.get("authors", [])
195
+ affiliations = config.get("affiliations", [])
196
+
197
+ # Handle multiple corresponding authors: keep only the last one
198
+ corresponding_indices = [i for i, author in enumerate(authors) if author.get("corresponding_author", False)]
199
+ if len(corresponding_indices) > 1:
200
+ # Unmark all but the last corresponding author
201
+ for idx in corresponding_indices[:-1]:
202
+ authors[idx]["corresponding_author"] = False
203
+ logger.warning(
204
+ f"Multiple corresponding authors found. Only keeping the last one: "
205
+ f"{authors[corresponding_indices[-1]].get('name', 'Unknown')}"
206
+ )
207
+
208
+ # Validate author data
209
+ validate_author_data(authors)
210
+
211
+ # Build affiliation map (shortname -> full data)
212
+ affiliation_map = {}
213
+ for affiliation in affiliations:
214
+ shortname = affiliation.get("shortname", "")
215
+ if shortname:
216
+ affiliation_map[shortname] = affiliation
217
+
218
+ # Process authors: decode emails
219
+ processed_authors = []
220
+ for author in authors:
221
+ author_copy = author.copy()
222
+
223
+ # Decode email64 if present
224
+ if "email64" in author_copy:
225
+ try:
226
+ author_copy["email"] = decode_email(author_copy["email64"])
227
+ except ValueError as e:
228
+ logger.warning(f"Failed to decode email64 for {author_copy.get('name', 'Unknown')}: {e}")
229
+ author_copy["email"] = ""
230
+ elif "email" not in author_copy:
231
+ author_copy["email"] = ""
232
+
233
+ processed_authors.append(author_copy)
234
+
235
+ # Generate TSV file
236
+ output_path.parent.mkdir(parents=True, exist_ok=True)
237
+
238
+ with open(output_path, "w", newline="", encoding="utf-8") as f:
239
+ writer = csv.writer(f, delimiter="\t", quoting=csv.QUOTE_MINIMAL, lineterminator="\n")
240
+
241
+ # Write header row
242
+ header = [
243
+ "Email",
244
+ "Institution",
245
+ "First Name",
246
+ "Middle Name(s)/Initial(s)",
247
+ "Last Name",
248
+ "Suffix",
249
+ "Corresponding Author",
250
+ "Home Page URL",
251
+ "Collaborative Group/Consortium",
252
+ "ORCiD",
253
+ ]
254
+ writer.writerow(header)
255
+
256
+ # Write author rows
257
+ for author in processed_authors:
258
+ row = format_author_row(author, affiliation_map)
259
+ writer.writerow(row)
260
+
261
+ logger.info(f"Generated bioRxiv author template: {output_path}")
262
+ return output_path
263
+
264
+
265
+ def prepare_biorxiv_package(
266
+ manuscript_path: Path,
267
+ output_dir: Path,
268
+ biorxiv_dir: Path | None = None,
269
+ ) -> Path:
270
+ """Prepare bioRxiv submission package.
271
+
272
+ Creates a directory containing:
273
+ - biorxiv_authors.tsv (author template)
274
+ - manuscript PDF
275
+ - source files (TeX, figures, bibliography)
276
+
277
+ Args:
278
+ manuscript_path: Path to the manuscript directory
279
+ output_dir: Path to the rxiv-maker output directory
280
+ biorxiv_dir: Path where bioRxiv submission files will be created.
281
+ If None, defaults to {output_dir}/biorxiv_submission
282
+
283
+ Returns:
284
+ Path to the bioRxiv submission directory
285
+
286
+ Raises:
287
+ FileNotFoundError: If required files are missing
288
+ """
289
+ output_path = Path(output_dir)
290
+
291
+ # Default bioRxiv directory to be inside the output directory
292
+ if biorxiv_dir is None:
293
+ biorxiv_dir = output_path / "biorxiv_submission"
294
+
295
+ biorxiv_path = Path(biorxiv_dir)
296
+
297
+ # Create clean bioRxiv directory
298
+ if biorxiv_path.exists():
299
+ shutil.rmtree(biorxiv_path)
300
+ biorxiv_path.mkdir(parents=True)
301
+
302
+ manuscript_name = manuscript_path.name if manuscript_path else "manuscript"
303
+ logger.info(f"Preparing bioRxiv submission package for '{manuscript_name}' in {biorxiv_path}")
304
+
305
+ # 1. Copy the bioRxiv authors TSV file (already generated)
306
+ tsv_source = output_path / "biorxiv_authors.tsv"
307
+ if not tsv_source.exists():
308
+ raise FileNotFoundError(
309
+ f"bioRxiv author template not found: {tsv_source}\n"
310
+ "Please run TSV generation first or ensure output directory is correct."
311
+ )
312
+ shutil.copy2(tsv_source, biorxiv_path / "biorxiv_authors.tsv")
313
+ logger.info("✓ Copied author template: biorxiv_authors.tsv")
314
+
315
+ # 2. Find and copy the manuscript PDF
316
+ pdf_files = list(output_path.glob("*.pdf"))
317
+ main_pdf = None
318
+ for pdf in pdf_files:
319
+ # Skip supplementary PDFs
320
+ if "supplementary" not in pdf.name.lower():
321
+ main_pdf = pdf
322
+ break
323
+
324
+ if not main_pdf:
325
+ logger.warning("⚠ No manuscript PDF found in output directory")
326
+ else:
327
+ shutil.copy2(main_pdf, biorxiv_path / main_pdf.name)
328
+ logger.info(f"✓ Copied manuscript PDF: {main_pdf.name}")
329
+
330
+ # 3. Copy source files for submission
331
+ # Copy TeX files
332
+ tex_files = list(output_path.glob("*.tex"))
333
+ for tex_file in tex_files:
334
+ shutil.copy2(tex_file, biorxiv_path / tex_file.name)
335
+ logger.info(f"✓ Copied TeX file: {tex_file.name}")
336
+
337
+ # Copy style file
338
+ style_file = output_path / "rxiv_maker_style.cls"
339
+ if style_file.exists():
340
+ shutil.copy2(style_file, biorxiv_path / "rxiv_maker_style.cls")
341
+ logger.info("✓ Copied style file: rxiv_maker_style.cls")
342
+
343
+ # Copy bibliography
344
+ bib_file = output_path / "03_REFERENCES.bib"
345
+ if bib_file.exists():
346
+ shutil.copy2(bib_file, biorxiv_path / "03_REFERENCES.bib")
347
+ logger.info("✓ Copied bibliography: 03_REFERENCES.bib")
348
+
349
+ # Copy FIGURES directory
350
+ figures_source = output_path / "FIGURES"
351
+ if figures_source.exists() and figures_source.is_dir():
352
+ figures_dest = biorxiv_path / "FIGURES"
353
+ shutil.copytree(figures_source, figures_dest)
354
+ figure_count = len(list(figures_dest.rglob("*")))
355
+ logger.info(f"✓ Copied FIGURES directory ({figure_count} files)")
356
+
357
+ logger.info(f"\n📦 bioRxiv package prepared in {biorxiv_path}")
358
+ return biorxiv_path
359
+
360
+
361
+ def create_biorxiv_zip(
362
+ biorxiv_path: Path,
363
+ zip_filename: str = "biorxiv_submission.zip",
364
+ manuscript_path: Path | None = None,
365
+ ) -> Path:
366
+ """Create a ZIP file for bioRxiv submission.
367
+
368
+ Args:
369
+ biorxiv_path: Path to the bioRxiv submission directory
370
+ zip_filename: Name of the ZIP file to create
371
+ manuscript_path: Optional manuscript path for naming
372
+
373
+ Returns:
374
+ Path to the created ZIP file
375
+ """
376
+ # Use manuscript-aware naming if manuscript path is provided
377
+ if manuscript_path and zip_filename == "biorxiv_submission.zip":
378
+ manuscript_name = manuscript_path.name
379
+ zip_filename = f"{manuscript_name}_biorxiv.zip"
380
+
381
+ zip_path = Path(zip_filename).resolve()
382
+
383
+ # Define auxiliary files that should be excluded
384
+ auxiliary_extensions = {".aux", ".blg", ".log", ".out", ".fls", ".fdb_latexmk", ".synctex.gz"}
385
+
386
+ logger.info(f"\n📁 Creating ZIP package: {zip_path}")
387
+
388
+ excluded_files = []
389
+ included_files = []
390
+
391
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
392
+ for file_path in biorxiv_path.rglob("*"):
393
+ if file_path.is_file():
394
+ # Check if file should be excluded (auxiliary files)
395
+ should_exclude = file_path.suffix.lower() in auxiliary_extensions
396
+
397
+ if should_exclude:
398
+ excluded_files.append(file_path.name)
399
+ continue
400
+
401
+ # Store files with relative paths
402
+ arcname = file_path.relative_to(biorxiv_path)
403
+ zipf.write(file_path, arcname)
404
+ included_files.append(str(arcname))
405
+
406
+ logger.info(f"✅ ZIP created: {zip_path}")
407
+ logger.info(f" Files included: {len(included_files)}")
408
+ if excluded_files:
409
+ logger.info(f" Files excluded: {len(excluded_files)} (auxiliary files)")
410
+
411
+ return zip_path
@@ -418,7 +418,7 @@
418
418
  {\sffamily\small\bfseries\itshape}
419
419
  {\thesubsubsection.}
420
420
  {0.5em}
421
- {#1. }
421
+ {#1.\enskip}
422
422
  []
423
423
  \titleformat{\paragraph}[runin]
424
424
  {\sffamily\small\bfseries}
@@ -427,7 +427,7 @@
427
427
  {#1}
428
428
  \titlespacing*{\section}{0pc}{1.5ex \@plus2pt \@minus1pt}{2pt}
429
429
  \titlespacing*{\subsection}{0pc}{1.2ex \@plus2pt \@minus1pt}{1pt}
430
- \titlespacing*{\subsubsection}{0pc}{1ex \@plus1pt \@minus0.5pt}{1pt}
430
+ \titlespacing*{\subsubsection}{0pc}{1ex \@plus1pt \@minus0.5pt}{8pt}
431
431
  \titlespacing*{\paragraph}{0pc}{0.8ex \@plus1pt \@minus0.5pt}{8pt}
432
432
 
433
433
  %% Figure caption style
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rxiv-maker
3
- Version: 1.18.5
3
+ Version: 1.19.1
4
4
  Summary: Write scientific preprints in Markdown. Generate publication-ready PDFs efficiently.
5
5
  Project-URL: Homepage, https://github.com/HenriquesLab/rxiv-maker
6
6
  Project-URL: Documentation, https://github.com/HenriquesLab/rxiv-maker#readme
@@ -1,5 +1,5 @@
1
1
  rxiv_maker/__init__.py,sha256=p04JYC5ZhP6dLXkoWVlKNyiRvsDE1a4C88f9q4xO3tA,3268
2
- rxiv_maker/__version__.py,sha256=i8ukZW5rVDoXBNiuInaBgxVXM535F7b6jCi26fK-JBw,51
2
+ rxiv_maker/__version__.py,sha256=1asCY_t8ZrspO60Mj4f6F7xZwm107oYPswgQnlvSKek,51
3
3
  rxiv_maker/rxiv_maker_cli.py,sha256=9Lu_mhFPXwx5jzAR6StCNxwCm_fkmP5qiOYdNuh_AwI,120
4
4
  rxiv_maker/validate.py,sha256=AIzgP59KbCQJqC9WIGfUdVv0xI6ud9g1fFznQkaGz5Q,9373
5
5
  rxiv_maker/cli/__init__.py,sha256=Jw0DTFUSofN-02xpVrt1UUzRcgH5NNd-GPNidhmNwpU,77
@@ -7,10 +7,11 @@ rxiv_maker/cli/__main__.py,sha256=OEaQSb9q7MK3MMkEJfOdzzJuDvr5LAeILtHQesdYxWU,13
7
7
  rxiv_maker/cli/framework.py,sha256=enBb4d48ZsgB-4H6zjzSGnMFEgPd1T7cnUjNcOOPKKY,3895
8
8
  rxiv_maker/cli/interactive.py,sha256=lgfZVFf1rdJKZtHPogNiEPSwhMlnU2_dSFU9CJjM-7E,18907
9
9
  rxiv_maker/cli/interactive_prompts.py,sha256=jhyCdP54TIYHzgAD84lwcyat3tCGVeK9vmyfLHuwL6I,11830
10
- rxiv_maker/cli/main.py,sha256=AwqEcq46MPu8T72iqO39RWemtiD7WiAeUkfxACMUdgs,8697
11
- rxiv_maker/cli/commands/__init__.py,sha256=jp_dRdPBJNhgZxA7ccAplbtESUBtb81k-boLA5yi3Fg,1396
10
+ rxiv_maker/cli/main.py,sha256=ieSKR954s2ppE5SiwDYa5Xwr3vh_xzQxwI1tf2tMcYE,8743
11
+ rxiv_maker/cli/commands/__init__.py,sha256=Kcof7u2l2XE1M4g3AAuio5cTX6WSt4ly1ROujHH54xw,1440
12
12
  rxiv_maker/cli/commands/arxiv.py,sha256=nlAS36lgTNjd6Hn1cdporXFZskf7-Jl-fZLvjxc6gjo,1245
13
13
  rxiv_maker/cli/commands/bibliography.py,sha256=3a4gNtY7Lvd5-mwIj-vCD5WwRDgMqPT37tJETthKYKA,2956
14
+ rxiv_maker/cli/commands/biorxiv.py,sha256=nKu7nAeTVeWH_3Q-gQnY5Y_Q9YnXZEC50MqQzEwqua4,1670
14
15
  rxiv_maker/cli/commands/build.py,sha256=v513o3duOD9YvKUoOTggqshqQzwD4THBVIAClJTnx60,3525
15
16
  rxiv_maker/cli/commands/cache_management.py,sha256=y58QsuSjzCz_IhY6iXnir8OoblHW5ZBMqItfnfjTP-Y,4577
16
17
  rxiv_maker/cli/commands/changelog.py,sha256=OzEay8E8sWfXagjXyWiKwfE-OC9UKokWB7mbINp36t4,8461
@@ -33,13 +34,13 @@ rxiv_maker/cli/commands/upgrade.py,sha256=UpdqEQwbNYmDMbSrYGv_pVd-7u8PPT3US5RVEN
33
34
  rxiv_maker/cli/commands/validate.py,sha256=3JghFQevJvQDQII4p_QWbQXMEUyDpM-t9-WxtaT4edo,1629
34
35
  rxiv_maker/cli/commands/version.py,sha256=VMlfSxxsrZH02d24MXLUDZfHBW39yZRpWxUpMhQ-X0Y,2737
35
36
  rxiv_maker/cli/framework/__init__.py,sha256=4FPXdP8J6v4eeEn46mwY0VtnwxjR1jnW_kTrXykQlQs,2704
36
- rxiv_maker/cli/framework/base.py,sha256=GcDFFOGVrLSlRgwDPmMkEAL60TbgT-maTyiCp1g-BrA,7886
37
+ rxiv_maker/cli/framework/base.py,sha256=HqjVHVJzBKLk9-lH6PQrVxYedvs1efcLZ94IxVq7rmE,11242
37
38
  rxiv_maker/cli/framework/cache_commands.py,sha256=J91UYLTsLTRoNdzuhAbNL2bJJovYYfX3T9jI8cNUBWU,9897
38
39
  rxiv_maker/cli/framework/config_commands.py,sha256=a1uOQkCCw3d4qlro3OwHIorcoNg03T_R4-HbfVb-hmQ,19336
39
40
  rxiv_maker/cli/framework/content_commands.py,sha256=RilxKeG2c1m2fu0CtWAvP3cGh11DGx9P-nh2kIewAg4,22596
40
41
  rxiv_maker/cli/framework/decorators.py,sha256=fh085e3k1CaLSMoZevt8hvgnEuejrf-mcNS-dwXoY_A,10365
41
42
  rxiv_maker/cli/framework/utility_commands.py,sha256=drIAc1TAYpne76gj7SZeZhPozVAY5uL9GFPVT_Ez0-E,26437
42
- rxiv_maker/cli/framework/workflow_commands.py,sha256=Csls8VGmNCWPjpY9PMfdIAdzDhD_ZmSfRkhdcUihzpk,32699
43
+ rxiv_maker/cli/framework/workflow_commands.py,sha256=CguePd76EjdT53ab9a9clv3S-FSpBAXioQKmto1VS3w,35422
43
44
  rxiv_maker/config/defaults.py,sha256=vHyLGVxe5-z9TLxu5f6NhquPvqQkER_KZv_j1I4_dHQ,3055
44
45
  rxiv_maker/config/validator.py,sha256=9XDPfo_YgasGt6NLkl6HIhaGh1fr6XsFNiXU2DSsivw,38299
45
46
  rxiv_maker/converters/__init__.py,sha256=d7WGsRwWqRQWO117IkKDP0Ap0ERiK0N2-dXHInye3_A,685
@@ -51,7 +52,7 @@ rxiv_maker/converters/figure_processor.py,sha256=xA7Z7-H4q4cPLAqlCjlcv4v_cYI-fLT
51
52
  rxiv_maker/converters/html_processor.py,sha256=n4AfjipeSi6uFpDtLiZb4GQYOQzYF_HD_FqdDXk84dY,5879
52
53
  rxiv_maker/converters/list_processor.py,sha256=QTt22XrkR5ESO12jvY0hCSFjagoCk2Htrc5bZ4kqon4,6430
53
54
  rxiv_maker/converters/math_processor.py,sha256=fwna-YFEbLFdsq6kllKAeAHv_6WS6rQn8R5JbeR1QQI,8324
54
- rxiv_maker/converters/md2tex.py,sha256=eO0gMjINrFmefJZARCEQO0ACmLgVE5mAccR_Fqz8zl4,21395
55
+ rxiv_maker/converters/md2tex.py,sha256=rDaSvQyfpuu7zkQOGrrXcAKnuSbQOIiRAvBM_hlIS1M,21443
55
56
  rxiv_maker/converters/python_executor.py,sha256=YMJO7A-re8EOCQjy-PKryFFBoxddK3g9R1VuaH2MfYY,37369
56
57
  rxiv_maker/converters/section_processor.py,sha256=7znzb_FYH2sCYH336l5J1ZdRaQv-ycdCN79RpI3Tc-w,5706
57
58
  rxiv_maker/converters/supplementary_note_processor.py,sha256=8wzQw2kN0Afg0iB7hPFdeWiDlDsFhhTkcRTvwCXIvjc,6689
@@ -99,6 +100,7 @@ rxiv_maker/engines/operations/generate_docs.py,sha256=8d_oVYUuRRqTuYN1KnJKqM5Ydp
99
100
  rxiv_maker/engines/operations/generate_figures.py,sha256=YeKzH6qVsuPGjtCsvWugLJoys6y73xTyO7Y5g30KM20,38730
100
101
  rxiv_maker/engines/operations/generate_preprint.py,sha256=wpKDAu2RLJ4amSdhX5GZ7hU-iTsTRt4etcEA7AZYp04,2662
101
102
  rxiv_maker/engines/operations/prepare_arxiv.py,sha256=cd0JN5IO-Wy9T8ab75eibyaA8_K8Gpwrz2F-95OMnx4,21551
103
+ rxiv_maker/engines/operations/prepare_biorxiv.py,sha256=X0U0UdFhTYldUMKjBJbu9IUL1xH8taIecmpC27HqndM,14353
102
104
  rxiv_maker/engines/operations/setup_environment.py,sha256=gERuThHTldH0YqgXn85995deHBP6csY1ZhCNgU6-vFg,12691
103
105
  rxiv_maker/engines/operations/track_changes.py,sha256=jJZ-XnTFx8TMvcnX8_9D7ydc0G01S1PnckLkxHRTX1g,24722
104
106
  rxiv_maker/engines/operations/validate.py,sha256=OVmtRVtG-r1hoA8IqYaNC-ijN1a5ixM3X5Z8Gda-O2M,17142
@@ -193,9 +195,9 @@ rxiv_maker/validators/doi/api_clients.py,sha256=tqdYUq8LFgRIO0tWfcenwmy2uO-IB1-G
193
195
  rxiv_maker/validators/doi/metadata_comparator.py,sha256=euqHhKP5sHQAdZbdoAahUn6YqJqOfXIOobNgAqFHlN8,11533
194
196
  rxiv_maker/tex/template.tex,sha256=_tPtxrurn3sKTt9Kfa44lPdPyT44vHbDUOGqldU9r2s,1378
195
197
  rxiv_maker/tex/style/rxiv_maker_style.bst,sha256=jbVqrJgAm6F88cow5vtZuPBwwmlcYykclTm8RvZIo6Y,24281
196
- rxiv_maker/tex/style/rxiv_maker_style.cls,sha256=6VDmZE0uvYWog6rcYi2K_NIM9-Pgjx9AFdRg_sTheK0,24374
197
- rxiv_maker-1.18.5.dist-info/METADATA,sha256=3MGWQNaVnAJM_7nypR3gtVuh5BTgnCwJ5eZ8S_MTrf8,18432
198
- rxiv_maker-1.18.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
199
- rxiv_maker-1.18.5.dist-info/entry_points.txt,sha256=ghCN0hI9A1GlG7QY5F6E-xYPflA8CyS4B6bTQ1YLop0,97
200
- rxiv_maker-1.18.5.dist-info/licenses/LICENSE,sha256=GSZFoPIhWDNJEtSHTQ5dnELN38zFwRiQO2antBezGQk,1093
201
- rxiv_maker-1.18.5.dist-info/RECORD,,
198
+ rxiv_maker/tex/style/rxiv_maker_style.cls,sha256=sMYmXtCZB6rEdZKqnY8f3-Jh6ku_3eZNKMcpNbQF-JQ,24380
199
+ rxiv_maker-1.19.1.dist-info/METADATA,sha256=ieFzn7Auj1L10FzS-PRbY569MADJqhXj_OmJXiNDVmU,18432
200
+ rxiv_maker-1.19.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
201
+ rxiv_maker-1.19.1.dist-info/entry_points.txt,sha256=ghCN0hI9A1GlG7QY5F6E-xYPflA8CyS4B6bTQ1YLop0,97
202
+ rxiv_maker-1.19.1.dist-info/licenses/LICENSE,sha256=GSZFoPIhWDNJEtSHTQ5dnELN38zFwRiQO2antBezGQk,1093
203
+ rxiv_maker-1.19.1.dist-info/RECORD,,