BUCKYVERSE

Date: 2025-02-24

Status

Accepted

Context

As part of our Elixir replatforming effort for the ebook conversion system, we need to provide effective preview capabilities for PDF output. PDF previewing is a critical component of our workflow for several reasons:

  1. Quality assurance: Team members need to visually inspect PDF outputs to verify proper formatting, layout, and typesetting
  2. Rapid iteration: The editing and conversion process requires quick feedback loops to address formatting issues
  3. Annotation needs: Content reviewers need to add comments and annotations directly to PDFs
  4. Cross-reference validation: Visual inspection helps confirm proper functioning of cross-references, indices, and bibliographies
  5. External tool integration: Our system needs to launch external viewers and potentially capture feedback

Our replatforming strategy emphasizes improved build environment management and quality control, both of which depend on reliable PDF preview capabilities.

The PDF viewer needs to satisfy several requirements:

Decision

We will use Skim (https://skim-app.sourceforge.io/) as our primary PDF viewer for the ELib platform, integrating it with our Elixir-based ebook conversion system.

Implementation Details

  1. Integration Method:
    We will use Elixir's System.cmd/3 and Port modules to integrate with Skim:

    defmodule Codexis.Viewers.PDF do
      def preview(pdf_path) do
        System.cmd("open", ["-a", "Skim", pdf_path])
      end
      
      def preview_with_options(pdf_path, options) do
        args = build_skim_args(pdf_path, options)
        Port.open({:spawn_executable, skim_executable()}, args)
      end
      
      defp skim_executable do
        "/Applications/Skim.app/Contents/MacOS/Skim"
      end
      
      defp build_skim_args(pdf_path, options) do
        base_args = [{:args, [pdf_path]}]
        # Add additional args based on options
        Enum.reduce(options, base_args, fn {key, value}, acc ->
          add_option(acc, key, value)
        end)
      end
      
      # Implementation for various options...
    end
    
  2. Advanced Features:

    defmodule Codexis.Viewers.PDF.Skim do
      # Refresh an already open PDF
      def refresh(pdf_path) do
        script = """
          tell application "Skim"
            set theDocs to get documents whose path is "#{pdf_path}"
            if length of theDocs > 0 then
              set theDoc to item 1 of theDocs
              revert theDoc
            end if
          end tell
        """
        System.cmd("osascript", ["-e", script])
      end
      
      # Open PDFs in comparison mode
      def compare(pdf_path1, pdf_path2) do
        script = """
          tell application "Skim"
            open "#{pdf_path1}"
            open "#{pdf_path2}"
            tell application "System Events"
              tell process "Skim"
                click menu item "Tile" of menu "Window" of menu bar 1
              end tell
            end tell
          end tell
        """
        System.cmd("osascript", ["-e", script])
      end
      
      # Get annotations from a PDF
      def get_annotations(pdf_path) do
        # Implementation to extract annotations using Skim's export functionality
      end
    end
    
  3. Integration with Build Pipeline:

    defmodule Codexis.Build.Formats.PDF do
      def generate_and_preview(content, options) do
        with {:ok, pdf_path} <- generate_pdf(content, options),
             {:ok, _} <- Codexis.Viewers.PDF.preview(pdf_path) do
          {:ok, pdf_path}
        end
      end
      
      def auto_refresh_preview do
        # Set up a file system watcher to auto-refresh Skim when PDFs change
        fs_path = Path.join([build_dir(), "*.pdf"])
        {:ok, watcher_pid} = FileSystem.start_link(dirs: [fs_path])
        FileSystem.subscribe(watcher_pid)
        
        # Handle file change events
        receive do
          {:file_event, _watcher_pid, {path, _events}} ->
            if Path.extname(path) == ".pdf" do
              Codexis.Viewers.PDF.Skim.refresh(path)
            end
        end
      end
    end
    
  4. Configuration Options:

    # In configuration
    config :elib, Codexis.Viewers.PDF,
      default_viewer: :skim,
      skim_path: "/Applications/Skim.app/Contents/MacOS/Skim",
      presentation_mode: false,
      auto_refresh: true,
      annotation_export_path: "annotations"
    

Consequences

Positive

  1. Enhanced productivity: Skim's quick rendering and navigation features speed up the review process
  2. Improved annotation workflow: Skim's robust annotation tools enable better feedback capture
  3. macOS integration: Native macOS app with support for AppleScript automation
  4. Lightweight: Faster startup time compared to alternatives like Adobe Acrobat
  5. Academic-focused features: Well-suited for technical documents with math content
  6. Auto-reload capability: Supports refreshing when PDFs are regenerated
  7. Multi-document support: Enables comparison between different versions or formats
  8. Free and open-source: No licensing costs or restrictions

Negative

  1. Platform limitation: Skim is macOS-only, limiting use on other operating systems
  2. AppleScript dependency: Automation relies on AppleScript, which may change over time
  3. Limited enterprise features: Lacks some advanced features found in commercial alternatives
  4. Integration complexity: Requires more custom code compared to using a web-based viewer
  5. External dependency: Relies on externally maintained software not controlled by our team

Mitigation Strategies

  1. Cross-platform fallback: Implement a fallback PDF viewing strategy for non-macOS platforms:

    def preview(pdf_path) do
      case :os.type() do
        {:unix, :darwin} -> open_with_skim(pdf_path)
        {:unix, _} -> System.cmd("xdg-open", [pdf_path])
        {:win32, _} -> System.cmd("start", ["", pdf_path], [shell: true])
      end
    end
    
  2. Web fallback option: Implement a browser-based PDF preview as an alternative:

    defmodule Codexis.Viewers.PDF.Web do
      def start_server(pdf_dir) do
        # Start a small web server to serve PDFs with PDF.js
      end
    end
    
  3. Viewer abstraction layer: Create an abstraction that allows for different viewers:

    defmodule Codexis.Viewers.PDF do
      def preview(pdf_path) do
        viewer = Application.get_env(:elib, :pdf_viewer, :skim)
        preview_with(viewer, pdf_path)
      end
      
      defp preview_with(:skim, path), do: Codexis.Viewers.PDF.Skim.preview(path)
      defp preview_with(:web, path), do: Codexis.Viewers.PDF.Web.preview(path)
      defp preview_with(:system, path), do: Codexis.Viewers.PDF.System.preview(path)
    end
    
  4. Sandbox annotations: Create a controlled annotation export/import system less dependent on specific viewer features

  5. Regular testing: Verify Skim compatibility with each macOS update

References