Skip to content

Instantly share code, notes, and snippets.

@trueroad
Last active September 18, 2024 10:19
Show Gist options
  • Select an option

  • Save trueroad/cc8bc24b703e7024b80ea517bc23f9dd to your computer and use it in GitHub Desktop.

Select an option

Save trueroad/cc8bc24b703e7024b80ea517bc23f9dd to your computer and use it in GitHub Desktop.

Revisions

  1. trueroad revised this gist Sep 18, 2024. 7 changed files with 654 additions and 0 deletions.
    186 changes: 186 additions & 0 deletions Makefile
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,186 @@
    #
    # LilyPond で生成した楽譜の符頭にバツ印やテキストを上書きしてみるテスト
    #

    #
    # 動作環境
    # LilyPond 2.24.3
    # Python 3.9
    # python-cairo, pypdf
    # Poppler
    # GNU make, sed, etc.
    #
    # 使い方
    # `make` で SVG が生成される。
    # .html をブラウザで開くと楽譜の符頭に上書きされたものが見える。
    #

    #
    # ターゲットの拡張子なしファイル名
    #

    TARGET_STEM = invention1

    #
    # 同梱の .ly をそのまま使用して生成するファイル
    #

    # PDF 経由で生成する SVG
    CROPPED_PDF_SVG = $(TARGET_STEM).cropped.pdf.svg
    # PDF 経由せず直接生成する SVG
    CROPPED_SVG = $(TARGET_STEM).cropped.svg

    #
    # ポイント&クリックを有効にした(アノテーションを含んだ) .ly と生成ファイル
    #

    # .ly
    ANNOT_LY = $(TARGET_STEM).annot.ly
    # リンクなどアノテーションを含んだ PDF
    ANNOT_CROPPED_PDF = $(TARGET_STEM).annot.cropped.pdf
    # PDF の CropBox とリンク情報のテキストファイル
    LINK_TXT = $(TARGET_STEM).annot.cropped.link.txt

    #
    # SMF (.mid) を出力する .ly と生成ファイル
    #

    # .ly
    MIDI_LY = $(TARGET_STEM).midi.ly
    # SMF (.mid)
    MIDI_MID = $(TARGET_STEM).midi.mid

    #
    # 音楽イベントを出力する .ly と生成ファイル
    #

    # .ly
    EVENT_LY = $(TARGET_STEM).event.ly
    # 音楽イベント一覧のテキストファイル
    NOTES_TEXT = $(TARGET_STEM).event-unnamed-staff.notes

    #
    # 符頭に上書きする SVG
    #

    # バツ印
    CROSS_SVG = $(TARGET_STEM).overwrite-notehead.cross.svg
    # テキスト(英文)
    CROSS_TEXT_ENGLISH = $(TARGET_STEM).overwrite-notehead.text.english.svg
    # テキスト(和文)
    CROSS_TEXT_JAPANESE = $(TARGET_STEM).overwrite-notehead.text.japanese.svg


    TARGET = $(CROPPED_PDF_SVG) $(CROPPED_SVG) \
    $(ANNOT_LY) $(ANNOT_CROPPED_PDF) $(LINK_TXT) \
    $(MIDI_LY) $(MIDI_MID) \
    $(EVENT_LY) $(NOTES_TEXT) \
    $(CROSS_SVG) $(CROSS_TEXT_ENGLISH) $(CROSS_TEXT_JAPANESE)

    all: $(TARGET)

    .PHONY: all clean

    SED = sed
    LILYPOND = lilypond
    PDFTOCAIRO = pdftocairo
    SHOW_PDF_LINK = ./show_pdf_link.py
    TEST_CAIRO_OVERWRITE_NOTEHEAD = ./test-cairo-overwrite-notehead.py

    target: $(TARGET)

    clean:
    $(RM) *~ $(TARGET)

    # PDF から SVG を生成する
    #
    # Poppler 付属の pdftocairo を使用する。
    # 文字はすべてアウトライン化される。
    # 寸法や位置関係など PDF と完全一致した SVG が出力される。
    # リンク等は消滅する。
    %.cropped.pdf.svg: %.cropped.pdf
    $(PDFTOCAIRO) -svg $< $@

    # LilyPond でクロップされた SVG を直接出力する
    #
    # 非クロップ版 SVG も出力されるので削除する。
    #
    # LilyPond 2.24.3 では PDF 出力と SVG 出力ではバックエンドの違いからか
    # クロップ範囲が微妙に異なるようで微妙にズレてしまう。
    # `Allegro` や `( = 96)` 等の文字は文字として出力され
    # フォントの違いなどからかそもそもの配置が異なる。
    # 音楽記号は音符休符などだけでなく拍子記号の `C` や強弱記号の `mf` も含め、
    # アウトライン化されたパスとして出力され、相対的な位置関係は一致する。
    # ただし、文字中の四分音符などは形状こそ一致するものの、
    # 文字の配置がことなるためか他の音楽記号とは位置がズレる。
    # cairo バックエンドを持つ LilyPond ならば PDF, SVG 双方とも
    # 同じ cairo バックエンドで出力すれば完全一致するのかもしれないが
    # LilyPond 2.24.3 ではデフォルト無効なので通常は使用できない。
    %.cropped.svg: %.ly
    $(LILYPOND) -dcrop --svg $<
    $(RM) $*.svg

    # LilyPond でクロップされた PDF を出力する
    #
    # PNG と非クロップ版 PDF も出力されてしまうので削除する。
    %.cropped.pdf: %.ly
    $(LILYPOND) -dcrop --pdf $<
    $(RM) $*.cropped.png $*.pdf

    # LilyPond で SMF (.mid) を出力する
    #
    # Linux や Cygwin ではデフォルト拡張子が .midi なので .mid を指定する。
    %.mid: %.ly
    $(LILYPOND) -dmidi-extension=mid $<

    # LilyPond で音楽イベントを出力する
    #
    # 譜の名前を付けていないのでファイル名に `unnamed` が付く。
    # 出力が上書きではなく追記になってしまうので一旦出力ファイルを消す。
    # `\layout {}` が無くて補完されてしまい PDF が出力されるので削除する。
    %-unnamed-staff.notes: %.ly
    $(RM) $@
    $(LILYPOND) $<
    $(RM) $*.pdf

    # ポイント&クリックを有効にした .ly を生成する
    %.annot.ly: %.ly
    $(SED) -r \
    -e 's/\\pointAndClickOff/\\pointAndClickOn/' \
    $< > $@

    # PDF を出力せずに SMF (.mid) を出力する .ly を生成する
    %.midi.ly: %.ly
    $(SED) -r \
    -e 's/\\layout \{\}/% \\layout \{\}/' \
    -e 's/% \\midi \{\}/\\midi \{\}/' \
    $< > $@

    # 音楽イベントを出力し(event-listener.ly をインクルードする)
    # ポイント&クリックを有効にし、
    # PDF を出力しない .ly を生成する。
    %.event.ly: %.ly
    $(SED) -r \
    -e 's/%%% INCLUDE %%%/\\include "event-listener.ly"/' \
    -e 's/\\pointAndClickOff/\\pointAndClickOn/' \
    -e 's/\\layout \{\}/% \\layout \{\}/' \
    $< > $@

    # PDF の CropBox とリンク情報を出力する
    %.link.txt: %.pdf
    $(SHOW_PDF_LINK) $< > $@

    # バツ印の SVG を生成する
    %.overwrite-notehead.cross.svg: %.annot.cropped.link.txt \
    %.event-unnamed-staff.notes
    $(TEST_CAIRO_OVERWRITE_NOTEHEAD) --cross $^ $@

    # テキスト(英文)の SVG を生成する
    %.overwrite-notehead.text.english.svg: %.annot.cropped.link.txt \
    %.event-unnamed-staff.notes
    $(TEST_CAIRO_OVERWRITE_NOTEHEAD) --text "Too long" $^ $@

    # テキスト(和文)の SVG を生成する
    %.overwrite-notehead.text.japanese.svg: %.annot.cropped.link.txt \
    %.event-unnamed-staff.notes
    $(TEST_CAIRO_OVERWRITE_NOTEHEAD) --text "長すぎ" $^ $@
    38 changes: 38 additions & 0 deletions invention1.ly
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,38 @@
    \version "2.24.3"

    %%% INCLUDE %%%
    \pointAndClickOff

    upper = \relative
    {
    \clef treble
    \key c \major
    \time 4/4
    \tempo "Allegro" 4 = 96

    r16_\mf c'( d e f d e c g'8) c b^\prall c |
    d16 g,( a b c a b g d'8) g f^\prall g |
    e16 a g f e g f a g f e d c e d f |
    }

    lower = \relative
    {
    \clef bass
    \key c \major
    \time 4/4

    r2 r16 c( d e f d e c |
    g'8) g, r4 r16 g'( a b c a b g |
    c8) b( c d e) g,( a b) |
    }

    \score
    {
    \new PianoStaff
    <<
    \new Staff = "upper" \upper
    \new Staff = "lower" \lower
    >>
    \layout {}
    % \midi {}
    }
    92 changes: 92 additions & 0 deletions show_pdf_link.py
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,92 @@
    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """
    Show PDF link annots.
    https://gist.github.com/trueroad/4810ab0845d86fb97510e4e3d3bbed13
    Copyright (C) 2024 Masamichi Hosoda.
    All rights reserved.
    Redistribution and use in source and binary forms, with or without
    modification, are permitted provided that the following conditions
    are met:
    * Redistributions of source code must retain the above copyright notice,
    this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright notice,
    this list of conditions and the following disclaimer in the documentation
    and/or other materials provided with the distribution.
    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    ARE DISCLAIMED.
    IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
    FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
    DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
    OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
    HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
    LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
    OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
    SUCH DAMAGE.
    """

    import sys
    from typing import cast, Optional, Union

    from pypdf import PageObject, PdfReader
    from pypdf.generic import (ArrayObject, DictionaryObject,
    IndirectObject, PdfObject, RectangleObject)


    def main() -> None:
    """Do main."""
    if len(sys.argv) != 2:
    print('Usage: ./show_pdf_link.py [INPUT.PDF]', file=sys.stderr)
    sys.exit(1)

    reader: PdfReader = PdfReader(sys.argv[1])

    page: PageObject
    for page in reader.pages:
    cropbox: RectangleObject = page.cropbox
    crop_left: float = cropbox.left.as_numeric()
    crop_bottom: float = cropbox.bottom.as_numeric()
    crop_right: float = cropbox.right.as_numeric()
    crop_top: float = cropbox.top.as_numeric()
    print(f'\nCropBox\t{crop_left}\t{crop_bottom}'
    f'\t{crop_right}\t{crop_top}')

    if '/Annots' in page:
    annot: Union[IndirectObject, PdfObject]
    for annot in cast(ArrayObject, page['/Annots']):
    if type(annot) is not IndirectObject:
    raise RuntimeError('It is not IndirectObject.')
    obj: Optional[Union[DictionaryObject, PdfObject]] = \
    annot.get_object()
    if obj is None:
    raise RuntimeError('get_object() returns None.')
    if type(obj) is not DictionaryObject:
    raise RuntimeError('It is not DictionaryObject.')
    if str(obj['/Subtype']) == '/Link':
    a: Union[DictionaryObject, PdfObject] = obj['/A']
    if type(a) is not DictionaryObject:
    raise RuntimeError('It is not DictionaryObject.')
    if str(a['/S']) == '/URI':
    rect: Union[ArrayObject, PdfObject] = obj['/Rect']
    if type(rect) is not ArrayObject:
    raise RuntimeError('It is not ArrayObject.')
    link_left: float = rect[0].as_numeric()
    link_bottom: float = rect[1].as_numeric()
    link_right: float = rect[2].as_numeric()
    link_top: float = rect[3].as_numeric()
    uri: str = str(a['/URI'])
    print(f'Link\t{link_left}\t{link_bottom}'
    f'\t{link_right}\t{link_top}'
    f'\t{uri}')


    if __name__ == '__main__':
    main()
    267 changes: 267 additions & 0 deletions test-cairo-overwrite-notehead.py
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,267 @@
    #!/usr/bin/env python3
    # -*- coding: utf-8 -*-
    """
    Test cairo overwrite notehead.
    https://gist.github.com/trueroad/cc8bc24b703e7024b80ea517bc23f9dd
    Copyright (C) 2024 Masamichi Hosoda.
    All rights reserved.
    Redistribution and use in source and binary forms, with or without
    modification, are permitted provided that the following conditions
    are met:
    * Redistributions of source code must retain the above copyright notice,
    this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright notice,
    this list of conditions and the following disclaimer in the documentation
    and/or other materials provided with the distribution.
    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    ARE DISCLAIMED.
    IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
    FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
    DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
    OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
    HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
    LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
    OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
    SUCH DAMAGE.
    """

    from dataclasses import dataclass
    import os
    import sys
    from typing import Any, Dict, Optional, TextIO, Union

    import cairo


    @dataclass(frozen=True)
    class rect_container:
    """Rectangle container class."""

    left: float
    top: float
    right: float
    bottom: float


    @dataclass(frozen=True)
    class point_and_click_container:
    """Point and click container class."""

    row: int
    column: int


    @dataclass(frozen=True)
    class note_container:
    """Note container class."""

    time: float
    noteno: int
    point_and_click: point_and_click_container


    class link_text:
    """Link text class."""

    def __init__(self) -> None:
    """__init__."""
    # PDF CropBox
    self.cropbox: rect_container
    # PDF links
    self.links: dict[point_and_click_container, rect_container] = {}
    # notes
    self.notes: list[note_container] = []

    def load_link(self, filename: Union[str, bytes, os.PathLike[Any]]
    ) -> None:
    """Load link text."""
    f: TextIO
    with open(filename, 'r') as f:
    line: str
    for line in f:
    items: list[str] = line.split()
    if len(items) == 5 and items[0] == 'CropBox':
    self.cropbox = rect_container(left=float(items[1]),
    bottom=float(items[2]),
    right=float(items[3]),
    top=float(items[4]))
    elif len(items) == 6 and items[0] == 'Link':
    if not items[5].startswith('textedit://'):
    continue
    pc_items: list[str] = items[5].split(':')
    self.links[
    point_and_click_container(
    row=int(pc_items[-3]),
    column=int(pc_items[-2]))] = \
    rect_container(
    left=float(items[1]),
    bottom=float(items[2]),
    right=float(items[3]),
    top=float(items[4]))

    def load_notes(self, filename: Union[str, bytes, os.PathLike[Any]]
    ) -> None:
    """Load notes."""
    f: TextIO
    with open(filename, 'r') as f:
    line: str
    for line in f:
    items: list[str] = line.split('\t')
    if len(items) == 6 and items[1] == 'note':
    pc_items: list[str] = items[5].split()
    if len(pc_items) != 3 or \
    pc_items[0] != 'point-and-click':
    raise RuntimeError('Notes file format error.')
    self.notes.append(note_container(
    time=float(items[0]),
    noteno=int(items[2]),
    point_and_click=point_and_click_container(
    row=int(pc_items[2]),
    column=int(pc_items[1]))))

    def calc_size(self) -> tuple[float, float]:
    """
    Calc SVG size.
    Returns:
    tuple[float, float]: SVG width (pt), height (pt)
    """
    width: float = self.cropbox.right - self.cropbox.left
    height: float = self.cropbox.top - self.cropbox.bottom
    return (width, height)

    def conv_axis(self, x: float, y: float) -> tuple[float, float]:
    """
    Convert axis PDF to SVG.
    Args:
    x (float): PDF x axis (pt)
    y (float): PDF y axis (pt)
    Returns:
    tuple[float, float]: SVG x axis (pt) ,y axis (pt)
    """
    svg_x: float = x - self.cropbox.left
    svg_y: float = self.cropbox.top - self.cropbox.bottom - y
    return (svg_x, svg_y)

    def conv_rect(self, rect: rect_container) -> rect_container:
    """
    Convert rect PDF to SVG.
    Args:
    rect (rect_container): PDF rect
    Returns:
    rect_container: SVG rect
    """
    svg_left: float
    svg_top: float
    svg_right: float
    svg_bottom: float
    svg_left, svg_top = self.conv_axis(rect.left, rect.top)
    svg_right, svg_bottom = self.conv_axis(rect.right, rect.bottom)
    return rect_container(left=svg_left, top=svg_top,
    right=svg_right, bottom=svg_bottom)

    def draw_crosses(self, context: cairo.Context) -> None:
    """Draw crosses."""
    nc: note_container
    for nc in self.notes:
    rect: rect_container = self.links[nc.point_and_click]
    draw_cross(context, self.conv_rect(rect))

    def draw_texts(self, context: cairo.Context, text: str) -> None:
    """Draw texts."""
    nc: note_container
    for nc in self.notes:
    rect: rect_container = self.links[nc.point_and_click]
    draw_text(context, self.conv_rect(rect), text)


    def draw_cross(context: cairo.Context, rect: rect_container) -> None:
    """Draw cross."""
    context.move_to(rect.left, rect.top)
    context.line_to(rect.right, rect.bottom)
    context.move_to(rect.right, rect.top)
    context.line_to(rect.left, rect.bottom)


    def draw_text(context: cairo.Context, rect: rect_container, text: str) -> None:
    """Draw text."""
    context.select_font_face('sans-serif')
    context.set_font_size(rect.bottom - rect.top)
    # 指定した座標がベースラインの左端になる模様。
    # よくあるフォントはベースラインの上が 0.88 下が 0.12 あるので、
    # 符頭の上下ピッタリに合わせるには以下のようにする。
    # (和文フォントはこれでだいたい合うが欧文はフォントや環境次第)
    context.move_to(rect.left, rect.top + (rect.bottom - rect.top) * 0.88)
    context.show_text(text)


    def main() -> None:
    """Do main."""
    print('Test cairo overwrite notehead\n'
    'https://gist.github.com/trueroad/'
    'cc8bc24b703e7024b80ea517bc23f9dd\n\n'
    'Copyright (C) 2024 Masamichi Hosoda.\n'
    'All rights reserved.\n')

    import argparse
    parser: argparse.ArgumentParser = argparse.ArgumentParser()
    parser.add_argument('LINK.TXT', help='(in) Link file.')
    parser.add_argument('STAFF.NOTES', help='(in) Notes file.')
    parser.add_argument('OUTPUT.SVG', help='(out) Output SVG.')
    parser.add_argument('--cross', help='Overwrite cross.',
    action='store_true')
    parser.add_argument('--text', help='Overwrite text.',
    type=str, nargs=1)

    args: argparse.Namespace = parser.parse_args()
    vargs: Dict[str, Any] = vars(args)

    link_filename: str = vargs['LINK.TXT']
    notes_filename: str = vargs['STAFF.NOTES']
    svg_filename: str = vargs['OUTPUT.SVG']
    b_cross: bool = vargs['cross']
    text_str: Optional[str] = None
    if vargs['text'] is not None:
    text_str = vargs['text'][0]

    print(f'Link filename : {link_filename}\n'
    f'Notes filename: {notes_filename}\n'
    f'Output SVG : {svg_filename}\n'
    f'Cross : {b_cross}\n'
    f'Text : {text_str}\n')

    lt: link_text = link_text()
    lt.load_link(link_filename)
    lt.load_notes(notes_filename)
    width: float
    height: float
    width, height = lt.calc_size()

    surface: cairo.SVGSurface
    # ファイル名、横 pt 、縦 pt
    with cairo.SVGSurface(svg_filename, width, height) as surface:
    context: cairo.Context = cairo.Context(surface)
    if b_cross:
    context.set_line_width(2)
    context.set_source_rgba(1, 0, 0, 0.7)
    lt.draw_crosses(context)
    context.stroke()
    if text_str is not None:
    context.set_source_rgba(1, 0, 0, 0.9)
    lt.draw_texts(context, text_str)


    if __name__ == '__main__':
    main()
    23 changes: 23 additions & 0 deletions test-cross.html
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,23 @@
    <!DOCTYPE html>
    <html>
    <head>
    <title>テスト(バツ印)</title>
    </head>
    <style>
    .relative
    {
    position: relative;
    }
    .absolute
    {
    position: absolute;
    }
    </style>
    <body>
    <h1>テスト(バツ印)</h1>
    <div class="relative">
    <img src="invention1.cropped.pdf.svg" class="absolute" />
    <img src="invention1.overwrite-notehead.cross.svg" class="absolute" />
    </div>
    </body>
    </html>
    24 changes: 24 additions & 0 deletions test-text-english.html
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,24 @@
    <!DOCTYPE html>
    <html>
    <head>
    <title>Test (text english)</title>
    </head>
    <style>
    .relative
    {
    position: relative;
    }
    .absolute
    {
    position: absolute;
    }
    </style>
    <body>
    <h1>Test (text english)</h1>
    <div class="relative">
    <img src="invention1.cropped.pdf.svg" class="absolute" />
    <img src="invention1.overwrite-notehead.text.english.svg"
    class="absolute" />
    </div>
    </body>
    </html>
    24 changes: 24 additions & 0 deletions test-text-japanese.html
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,24 @@
    <!DOCTYPE html>
    <html>
    <head>
    <title>テスト(テキスト和文)</title>
    </head>
    <style>
    .relative
    {
    position: relative;
    }
    .absolute
    {
    position: absolute;
    }
    </style>
    <body>
    <h1>テスト(テキスト和文)</h1>
    <div class="relative">
    <img src="invention1.cropped.pdf.svg" class="absolute" />
    <img src="invention1.overwrite-notehead.text.japanese.svg"
    class="absolute" />
    </div>
    </body>
    </html>
  2. trueroad created this gist Sep 18, 2024.