No Vendor Lock-In

Xiao Hanyu,LaTeXVendor Lock-InOverleafSource Code

Table of Contents

Fundamental principle

There are countless resume builders on the market, most of which offer sleek interfaces and professional-looking templates, but they often come with a major drawback: vendor lock-in. Once you create a resume on these platforms, you’re typically forced to maintain a subscription in order to make future edits or access your resume. These subscriptions aren’t cheap—many services charge between $15 to $30 per month, which is an ongoing expense even if you don’t create or update your resumes every month. This practice essentially holds your professional documents hostage, requiring ongoing payment just to maintain access to your own career materials.

VendorPricing Plan
resume.io$2.95/week, $44.95/6 months, $74.95/year
Novorésumé$19.99/month, $39.99/quarter, $99.99/year
CVwizard$0.99 for 14 days free trials, $14.99/month
Rezi$29/month, $249 one-time for lifetime access
VisualCV$24/month (monthly), $15/month (quarterly)
Standard Resume$19/month

PPResume is built on a fundamental principle: your resumes belong to you, not us. While we provide a powerful and intuitive platform for creating, updating and hosting your resumes, we believe that users should have complete control over their data and the ability to re-create their resumes independently of our service. That being said, we do provide an official eject opt-out option for you to take your resumes with you and keep them forever.

PPResume Eject

The curse of PDF

By default, PPResume produces PDF as the output format for your resumes.

PDF (Portable Document Format) is the golden standard documentation format that is widely recognized as the first choice for documents sharing. Its ability to preserve document formatting across different devices and platforms makes it an ideal choice for documents such as resumes, contracts, etc., ensuring that your carefully crafted documents look the same whether viewed on a computer, tablet, or smartphone.

However, PDF is not perfect, the greatest curse of PDF is that it is generally not editable. If you want to edit the PDF files, you need to use specialized PDF editors and most of them are not free.

Here is a non-exhaustive list of the professional PDF editors and their pricing plans:

PDF EditorFree Version/TrialPricing Plan (For Individuals)
Adobe Acrobat DC7 daysStandard: $12.99/month, Pro: $19.99/month
Foxit PDF Editor14 daysPDF Editor: $10.99/month, PDF Editor+: $13.99/month
Nitro Pro14 daysNitro Pro: $12.99/month, Nitro PDF Pro+: $250 for one-time purchase
SmallpdfLimited featuresPro: $12/month
Sejda PDF EditorLimited featuresWeb Week Pass: $5, Web Monthly: $7.50/month, Desktop + Web Annual: $63/year
PDF Expert7 days$6.67/month, Lifetime: $139.99 one-time purchase

By providing the LaTeX source code along with the PDF output, we’re ensuring that users always have a way to edit and re-create their resumes, even if they decide to stop using our service.

This commitment to user freedom is why we’ve made the decision to provide the complete LaTeX source code for every resume created on our platform. As a free and open-source typesetting system, LaTeX ensures that users are never locked into our platform and can re-create their resumes anywhere, anytime.

Show time

As we mentioned above, you fill the form with your personal information, PPResume takes the rest and produces pixel perfect PDF for your resumes. 1700 users have already created their resumes on PPResume at the time of writing.

PPResume Output PDF

From now on, we also provide the LaTeX source code for your resumes, so you can download the code and re-create the PDF from the code by yourself at any time, any device.

PPResume Output Code

We will do our best to make sure that the generated LaTeX source code can be compiled by LaTeX and produce the same PDF output as the one on PPResume. In general, there are two ways to generate the PDF from the LaTeX source code:

  1. Overleaf
  2. Local LaTeX Environment

You can check our documentation for more details.

PPResume Overleaf

Last but not least, we’ve prepared a live demo for you to see how the LaTeX source code is generated and compiled:

Engineering

Please ignore this section if you are not interested in the engineering details.

Initially, we use mustache.js to generate both the LaTeX source code and the PDF on the server side, which makes things a bit complicated. For example, each time we generate a new PDF, we have to first query the database to get the template, and then render the template with the user’s data to generate the LaTeX source code, and then compile the LaTeX source to PDF.

The template code looks something like this:

\documentclass[a4paper, serif, <= &layout.typography.fontSize =>]{moderncv}
\usepackage{fontawesome5}
 
\moderncvstyle{banking}
\moderncvcolor{black}
\usepackage[utf8]{inputenc}
 
% page layout/margin control
\usepackage[top=<= &layout.margins.top =>,
bottom=<= &layout.margins.bottom=>,
left=<= &layout.margins.left=>,
right=<= &layout.margins.right=>]{geometry}
 
<= #layout.computed.environment.isSpanish =>
\usepackage[T1]{fontenc}
% [spanish]{babel} has some conflicting issues with moderncv, ref:
% https://tex.stackexchange.com/a/140161/36007
\usepackage[spanish,es-lcroman]{babel}
<= /layout.computed.environment.isSpanish =>
 
% English font settings
\defaultfontfeatures{Ligatures=TeX}
\setmainfont[Ligatures={Common}, Numbers={<= &layout.typography.fontSpec.numbers =>}, <= #layout.computed.environment.isCJK =>ItalicFont={<= &layout.computed.environment.mainFont =>}, <= /layout.computed.environment.isCJK =>Variant=01]{<= &layout.computed.environment.mainFont =>}
 
\name{<= &content.basics.name =>}{}
\title{<= &content.basics.headline =>}
 
<= #content.basics.phone =>
\phone[mobile]{<= &content.basics.phone =>}
<= /content.basics.phone =>
 
<= #content.basics.email =>
\email{<= &content.basics.email =>}
<= /content.basics.email =>
 
<= #content.computed.urls =>
\extrainfo{<= &content.computed.urls =>}
<= /content.computed.urls =>
 
<= #content.location.computed.fullAddress =>
\address{<= &content.location.computed.fullAddress =>}{}{}
<= /content.location.computed.fullAddress =>
 
<= ^layout.page.showPageNumbers =>
\nopagenumbers{}
<= /layout.page.showPageNumbers =>
 
\begin{document}
\maketitle
 
<= #content.basics.computed.summary =>
\section{<= &content.computed.sectionNames.basics =>}
\cvitem{}{\begin{minipage}{\textwidth}<= &content.basics.computed.summary =>\end{minipage}}
<= /content.basics.computed.summary =>
 
<= #content.education.length =>
\section{<= &content.computed.sectionNames.education =>}
<= /content.education.length =>
 
<= #content.education =>
\cventry{<= #computed.startDate =><= &computed.dateRange =><= /computed.startDate =>}
{<= #computed.degreeAreaAndScore =><= &computed.degreeAreaAndScore =><= /computed.degreeAreaAndScore =>}
{<= &institution =>}
{\href{<= &url =>}{<= &url =>}}
{}
{\begin{minipage}{\textwidth}
<= &computed.summary =>%
<= #computed.courses =>\textbf{<= &templateTranslations.terms.Courses =>}<= &templateTranslations.punctuations.Colon =><= &computed.courses =><= /computed.courses =>%
\end{minipage}}
<= /content.education =>
 
<= #content.work.length =>
\section{<= &content.computed.sectionNames.work =>}
<= /content.work.length =>
 
<= #content.work =>
\cventry{<= #computed.startDate =><= &computed.dateRange =><= /computed.startDate =>}
{<= &position =>}
{<= &name =>}
{\href{<= &url =>}{<= &url =>}}
{}
{\begin{minipage}{\textwidth}
<= &computed.summary =>%
<= #computed.keywords =>\textbf{<= &templateTranslations.terms.Keywords =>}<= &templateTranslations.punctuations.Colon =><= &computed.keywords =><= /computed.keywords =>%
\end{minipage}}
<= /content.work =>
 
<= #content.languages.length =>
\section{<= &content.computed.sectionNames.languages =>}
<= /content.languages.length =>
 
<= #content.languages =>
\cvlanguage{<= &computed.language =>}{<= &computed.fluency =>}{}
<= /content.languages =>
 
<= #content.skills.length =>
\section{<= &content.computed.sectionNames.skills =>}
<= /content.skills.length =>
 
<= #content.skills =>
\cvlanguage{<= &name =>}{<= &computed.level =>}{<= #computed.keywords =>\textbf{<= &templateTranslations.terms.Keywords =>}<= &templateTranslations.punctuations.Colon =><= &computed.keywords =><= /computed.keywords =>}
<= /content.skills =>
 
\end{document}

The server side code that generates the LaTeX source code and the PDF looks something like this:

export const generatePDFHandler: PayloadHandler = async (req, res) => {
  const { slug } = req.body
 
  // Step 0: get resume and template
  const resume = await findResume(req)
  const template = await findTemplate()
 
  // Step 1: render resume content to tex
  const resumeTeXContent = resumeToTeX(resume as Resume, template)
 
  // Step 2: prepare temp directory for compiling tex to pdf
  const { resumeTexDir, resumeTexFile } = await prepareTeXDir(
    req,
    resumeTeXContent
  )
 
  // Step 3: compile tex to pdf
  const resumePDFFile = await compileTeXToPDF(resumeTexDir, resumeTexFile)
 
  // Step 4: return the pdf file
  // ...
}

The greatest benefit of this approach is that once we found any issues with the LaTeX template, we can fix it simply by updating a record in the database, and the changes will take effect immediately, without having to redeploy the service at all. However, this approach also has some drawbacks, for example, mustache’s capabilities are very limited, and it is very hard for us to implement advanced PDF customization features like section recordering, so we decided to do a major refactoring by rewriting the LaTeX source code generation in pure TypeScript:

The new code looks something like this:

class ModerncvBase extends Renderer {
  style: ModerncvStyle
 
  constructor(resume: Resume, style: ModerncvStyle) {
    super(transformResume(resume))
    this.style = style
  }
 
  renderPreamble(): string {
    return joinNonEmptyString([
      // document class
      renderDocumentClassConfig(this.resume, DocumentClass.Moderncv),
      renderModerncvConfig(this.resume, this.style),
 
      // layout
      renderLayoutConfig(this.resume),
 
      // language specific
      renderCTeXConfig(this.resume),
      renderSpanishConfig(this.resume),
 
      // fontspec
      // note that loading order of fontspec and babel packages matters here
      // babel package should be loaded before fontspec package, otherwise
      // Spanish resumes cannot render correct font styles in my testing,
      // reason still unknown though
      renderFontspecConfig(this.resume),
    ])
  }
 
  renderBasics(): string {
    const {
      content: {
        basics: { name, headline, phone, email },
      },
    } = this.resume
 
    return joinNonEmptyString(
      [
        showIf(!isEmptyString(name), `\\name{${name}}{}`),
        showIf(!isEmptyString(headline), `\\title{${headline}}`),
        showIf(!isEmptyString(phone), `\\phone[mobile]{${phone}}`),
        showIf(!isEmptyString(email), `\\email{${email}}`),
      ],
      '\n'
    )
  }
 
  renderLocation(): string {
    // render location
  }
 
  renderProfiles(): string {
    // render profiles
  }
 
  renderSummary(): string {
    // render summary
  }
 
  renderEducation(): string {
    const {
      content: {
        computed: { sectionNames },
        education,
      },
      layout,
    } = this.resume
 
    const {
      punctuations: { Colon },
      terms: { Courses },
    } = getTemplateTranslations(layout.locale?.language)
 
    if (!education.length) {
      return ''
    }
 
    return `\\section{${sectionNames.education}}
 
${education
  .map(
    ({
      computed: { startDate, dateRange, degreeAreaAndScore, summary, courses },
      institution,
      url,
    }) => `\\cventry{${showIf(!isEmptyString(startDate), dateRange)}}
        {${degreeAreaAndScore}}
        {${institution}}
        {\\href{${url}}{${url}}}
        {}
        {${showIf(
          !isEmptyString(summary) || !isEmptyValue(courses),
          `${joinNonEmptyString(
            [
              summary,
              showIf(
                !isEmptyValue(courses),
                `\\textbf{${Courses}}${Colon}${courses}`
              ),
            ],
            '\n'
          )}`
        )}}`
  )
  .join('\n\n')}`
  }
 
  renderWork(): string {
    // render work
    // ...
  }
 
  renderLanguages(): string {
    // render languages
    // ...
  }
 
  renderAwards(): string {
    // render awards
    // ...
  }
 
  renderPublications(): string {
    // render publications
    // ...
  }
 
  renderReferences(): string {
    // render references
    // ...
  }
 
  renderProjects(): string {
    // render projects
    // ...
  }
 
  renderInterests(): string {
    // render interests
    // ...
  }
 
  render(): string {
    return this.generateTeX()
  }
 
  private generateTeX(): string {
    return `${joinNonEmptyString([
      this.renderPreamble(),
      this.renderBasics(),
      this.renderLocation(),
      this.renderProfiles(),
    ])}
 
\\begin{document}
 
\\maketitle
 
${joinNonEmptyString([
  this.renderSummary(),
  this.renderEducation(),
  this.renderWork(),
  this.renderLanguages(),
  this.renderSkills(),
  this.renderAwards(),
  this.renderCertificates(),
  this.renderPublications(),
  this.renderReferences(),
  this.renderProjects(),
  this.renderInterests(),
  this.renderVolunteer(),
])}
\\end{document}`
  }
}

The new approach is much more flexible and powerful, which brings us two great benefits:

  1. We can now implement more advanced PDF customization features such as section recordering.
  2. Both frontend and backend can generate LaTeX source code at will, so we can expose the code generation capability to frontend, that is why in our demo the code generation happens in real time with no delay at all.

Next

Our commitment to no vendor lock-in is not just about providing source code—it’s about our respect for users’ freedom over their own data and privacy: your resume should truly be yours, not tied to any single platform or service, you have the right to dislike us, get your data back, keep it in your own pocket forever.

While we are maintaining our core principle, we will also continue to expand PPResume’s capabilities as mentioned above:

Stay tuned for more updates!