Last active
June 30, 2025 21:54
-
-
Save koppi/81a0b97ccd63008eff20ffa38f19908e to your computer and use it in GitHub Desktop.
google contacts vcf 2 latex perl script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Simple Makefile for building LaTeX PDF with xelatex | |
VCF = contacts.vcf | |
TEX = contacts.tex | |
PDF = contacts.pdf | |
PL = vcf2latex.pl | |
all: $(PDF) | |
$(TEX): $(VCF) $(PL) | |
perl $(PL) $(VCF) > $(TEX) | |
$(PDF): $(TEX) | |
xelatex -interaction=nonstopmode $(TEX) | |
xelatex -interaction=nonstopmode $(TEX) | |
clean: | |
rm -f $(TEX) $(PDF) *.aux *.log *.out *.toc | |
.PHONY: all clean |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/perl | |
use strict; | |
use warnings; | |
use URI::Escape; | |
# Enhanced LaTeX escape function | |
sub latex_escape { | |
my ($str) = @_; | |
return '' unless defined $str; | |
# Replace Unicode smart quotes and dashes with LaTeX equivalents | |
$str =~ s/[\x{2018}\x{2019}\x{201A}\x{201B}]/`/g; # single quotes | |
$str =~ s/[\x{201C}\x{201D}\x{201E}\x{201F}]/``/g; # double quotes | |
$str =~ s/\x{2013}/--/g; # en dash | |
$str =~ s/\x{2014}/---/g; # em dash | |
$str =~ s/\x{2026}/\\ldots{}/g; # ellipsis | |
# Escape LaTeX special characters | |
$str =~ s/\\/\\textbackslash{}/g; | |
$str =~ s/([#\$%&_{}])/\\$1/g; | |
$str =~ s/\^/\\textasciicircum{}/g; | |
$str =~ s/~/\\textasciitilde{}/g; | |
$str =~ s/</\\textless{}/g; | |
$str =~ s/>/\\textgreater{}/g; | |
$str =~ s/"/''/g; | |
# Ligature and typographical fixes | |
$str =~ s/ffi/{ffi}/g; | |
$str =~ s/ffl/{ffl}/g; | |
$str =~ s/ff/{ff}/g; | |
$str =~ s/fi/{fi}/g; | |
$str =~ s/fl/{fl}/g; | |
# Remove control characters, except tab/newline | |
$str =~ s/\r//g; | |
$str =~ s/\n/ /g; | |
$str =~ s/\xA0/ /g; | |
$str =~ s/[\x00-\x08\x0B-\x1F\x7F]//g; | |
# Trim | |
$str =~ s/^\s+//; | |
$str =~ s/\s+$//; | |
return $str; | |
} | |
# Format BDAY to ISO (YYYY-MM-DD) | |
sub format_iso_bday { | |
my ($bday_raw) = @_; | |
return '' unless defined $bday_raw && $bday_raw ne ''; | |
# vCard BDAY can be YYYY, YYYYMMDD, YYYY-MM-DD, YYYYMM, etc. | |
# We'll try to reformat to YYYY-MM-DD if possible. | |
if ($bday_raw =~ /^(\d{4})-?(\d{2})-?(\d{2})$/) { | |
# YYYYMMDD or YYYY-MM-DD | |
return sprintf("%04d-%02d-%02d", $1, $2, $3); | |
} elsif ($bday_raw =~ /^(\d{4})-?(\d{2})$/) { | |
# YYYYMM or YYYY-MM | |
return sprintf("%04d-%02d", $1, $2); | |
} elsif ($bday_raw =~ /^(\d{4})$/) { | |
# YYYY | |
return $1; | |
} else { | |
# fallback: escape and print as-is | |
return latex_escape($bday_raw); | |
} | |
} | |
# Extract last name for sorting | |
sub get_last_name { | |
my ($full_name) = @_; | |
return '' unless defined $full_name and $full_name ne ''; | |
# Remove leading/trailing whitespace | |
$full_name =~ s/^\s+//; | |
$full_name =~ s/\s+$//; | |
# Try to handle "Surname, Firstname" (comma) | |
if ($full_name =~ /^([^,]+),/) { | |
return lc $1; | |
} | |
# Otherwise assume last word is the last name ("Firstname Middlename Lastname") | |
elsif ($full_name =~ /(\S+)$/) { | |
return lc $1; | |
} | |
return lc $full_name; | |
} | |
my $vcf_file = shift or die "Usage: $0 contacts.vcf\n"; | |
open my $fh, '<', $vcf_file or die "Cannot open $vcf_file: $!"; | |
# Gather rows grouped by CATEGORIES (comma-separated list) | |
my %groups; | |
my ($name, $email, $email_raw, $phone, $phone_raw, $org, $title, $address, $address_raw, $birthday, @categories); | |
while (<$fh>) { | |
chomp; | |
if (/^FN:(.+)/) { $name = latex_escape($1); } | |
if (/^EMAIL.*:(.+)/) { $email_raw = $1; $email = latex_escape($email_raw); } | |
if (/^TEL.*:(.+)/) { $phone_raw = $1; $phone = latex_escape($phone_raw); } | |
if (/^ORG:(.+)/) { $org = latex_escape($1); } | |
if (/^TITLE:(.+)/) { $title = latex_escape($1); } | |
if (/^ADR.*:(.+)/) { | |
$address_raw = $1; | |
my $adr = $address_raw; | |
$adr =~ s/;/, /g; | |
$address = latex_escape($adr); | |
} | |
if (/^BDAY:(.+)/) { $birthday = format_iso_bday($1); } | |
if (/^CATEGORIES:(.+)/) { @categories = map { latex_escape($_) } split(/,/, $1); } | |
if (/^END:VCARD/) { | |
my $email_field = ($email && $email_raw) ? "\\href{mailto:$email_raw}{$email}" : ''; | |
my $phone_field = ($phone && $phone_raw) ? "\\href{tel:$phone_raw}{$phone}" : ''; | |
my $address_field = ''; | |
if ($address && $address_raw =~ /\w/) { | |
my $maps_url = "https://maps.google.com/?q=" . uri_escape($address_raw); | |
$address_field = "\\href{$maps_url}{$address}"; | |
} else { | |
$address_field = $address || ''; | |
} | |
my @row = ( | |
$name || '', $org || '', $phone_field, $email_field, $address_field, $birthday || '' | |
); | |
if (!@categories) { push @categories, "Uncategorized"; } | |
for my $cat (@categories) { | |
push @{ $groups{$cat} }, [@row]; | |
} | |
($name, $email, $email_raw, $phone, $phone_raw, $org, $title, $address, $address_raw, $birthday, @categories) = (undef) x 11; | |
} | |
} | |
close $fh; | |
# Sort rows in each group by last name | |
for my $cat (keys %groups) { | |
my @sorted = sort { | |
get_last_name($a->[0]) cmp get_last_name($b->[0]) | |
} @{ $groups{$cat} }; | |
$groups{$cat} = \@sorted; | |
} | |
# LaTeX header with ngerman package, printable area and grouped tables, with page X / Y in center bold footer | |
print <<"HEADER"; | |
\\documentclass{article} | |
\\usepackage[a4paper,landscape,margin=20mm]{geometry} | |
\\usepackage[ngerman]{babel} | |
\\usepackage{hyperref} | |
\\usepackage{fontspec} | |
\\usepackage{supertabular} | |
\\usepackage{lastpage} | |
\\usepackage{fancyhdr} | |
\\pagestyle{fancy} | |
\\fancyhf{} | |
\\renewcommand{\\thepage}{\\textarabic{\\arabic{page}}} | |
\\newcommand{\\totalpages}{\\pageref{LastPage}} | |
\\fancyfoot[C]{\\textbf{\\thepage{} / \\totalpages{}}} | |
\\usepackage{arabtex} | |
\\usepackage{utf8} | |
\\setcode{utf8} | |
\\begin{document} | |
\\pagenumbering{arabic} | |
\\setlength{\\tabcolsep}{4pt} | |
HEADER | |
my $tablehead = <<'TABLEHEAD'; | |
\tablehead{ | |
\hline | |
\textbf{Name} & \textbf{Organization} & \textbf{Phone} & \textbf{Email} & \textbf{Address} & \textbf{Birthday} \\ | |
\hline | |
} | |
TABLEHEAD | |
my $colspec = '{|p{0.16\textwidth}|p{0.16\textwidth}|p{0.16\textwidth}|p{0.16\textwidth}|p{0.20\textwidth}|p{0.16\textwidth}|}'; | |
for my $cat (sort keys %groups) { | |
print "\\section*{", latex_escape($cat), "}\n"; | |
print $tablehead; | |
print "\\begin{supertabular}$colspec\n\\hline\n"; | |
for my $row (@{ $groups{$cat} }) { | |
print join(' & ', @$row), " \\\\\n\\hline\n"; | |
} | |
print "\\end{supertabular}\n\n"; | |
} | |
print <<"FOOTER"; | |
\\end{document} | |
FOOTER |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment