Skip to content

Instantly share code, notes, and snippets.

@koppi
Last active June 30, 2025 21:54
Show Gist options
  • Save koppi/81a0b97ccd63008eff20ffa38f19908e to your computer and use it in GitHub Desktop.
Save koppi/81a0b97ccd63008eff20ffa38f19908e to your computer and use it in GitHub Desktop.
google contacts vcf 2 latex perl script
# 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
#!/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