main.go

  1package main
  2
  3import (
  4	"bytes"
  5	"embed"
  6	"fmt"
  7	"image"
  8	"image/color"
  9	"image/png"
 10	"io/ioutil"
 11	"os"
 12	"strings"
 13
 14	"gioui.org/font/opentype"
 15	"gioui.org/gpu/headless"
 16	"gioui.org/io/router"
 17	"gioui.org/layout"
 18	"gioui.org/op"
 19	"gioui.org/op/clip"
 20	"gioui.org/op/paint"
 21	"gioui.org/text"
 22	"gioui.org/unit"
 23	"gioui.org/widget/material"
 24	"github.com/adrg/frontmatter"
 25	"github.com/go-git/go-git/v5"
 26	flag "github.com/spf13/pflag"
 27	"gopkg.in/yaml.v3"
 28)
 29
 30var (
 31	flagHelp          *bool   = flag.BoolP("help", "h", false, "Show the help message")
 32	flagInput         *string = flag.StringP("input", "i", "", "Path to input Markdown")
 33	flagOutput        *string = flag.StringP("output", "o", "", "Path to output PNG")
 34	flagMetaSize      *int    = flag.IntP("metasize", "m", 40, "Size of font for meta information")
 35	flagPostTitleSize *int    = flag.IntP("posttitlesize", "p", 60, "Size of font for post title")
 36	flagSiteTitleSize *int    = flag.IntP("sitetitlesize", "s", 50, "Size of font for site title")
 37)
 38
 39//go:embed fonts
 40var fontEmbed embed.FS
 41
 42func main() {
 43	flag.Parse()
 44
 45	if *flagHelp {
 46		help()
 47		os.Exit(0)
 48	}
 49
 50	validateFlags()
 51
 52	postTitle, postDate, postContent := getPostInfo(*flagInput)
 53	postReadTime := getReadTime(postContent)
 54	siteTitle := getSiteTitle()
 55	dateEdited := getGitDate(*flagInput)
 56
 57	collection := fontCollection()
 58
 59	sz := image.Point{X: 1200, Y: 630}
 60
 61	w, err := headless.NewWindow(sz.X, sz.Y)
 62	if err != nil {
 63		panic(err)
 64	}
 65	th := material.NewTheme(collection)
 66	gtx := layout.Context{
 67		Ops:   new(op.Ops),
 68		Queue: new(router.Router),
 69	}
 70	w.Frame(gtx.Ops)
 71	layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
 72		gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .6)
 73		gtx.Constraints.Min.X = gtx.Constraints.Max.X
 74		gtx.Constraints.Min.Y = gtx.Constraints.Max.Y
 75
 76		return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle, Spacing: layout.SpaceAround}.Layout(gtx,
 77
 78			layout.Rigid(layout.Spacer{Height: 50}.Layout),
 79
 80			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 81				gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
 82				gtx.Constraints.Min.X = gtx.Constraints.Max.X
 83				title := material.Label(th, unit.Sp(float32(*flagPostTitleSize)), postTitle)
 84				title.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
 85				title.Alignment = text.Middle
 86				return title.Layout(gtx)
 87			}),
 88
 89			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 90				gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .7)
 91				gtx.Constraints.Min.X = gtx.Constraints.Max.X
 92				return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
 93					layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 94						return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
 95							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
 96								rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Reading Time: ")
 97								rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
 98								rTime.Alignment = text.Start
 99								return rTime.Layout(gtx)
100							}),
101							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
102								rTime := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(postReadTime, " minutes"))
103								rTime.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
104								rTime.Alignment = text.End
105								return rTime.Layout(gtx)
106							}),
107						)
108					}),
109
110					layout.Rigid(func(gtx layout.Context) layout.Dimensions {
111						return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
112							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
113								pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Published: ")
114								pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
115								pDate.Alignment = text.Start
116								return pDate.Layout(gtx)
117							}),
118							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
119								pDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(postDate))
120								pDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
121								pDate.Alignment = text.End
122								return pDate.Layout(gtx)
123							}),
124						)
125					}),
126
127					layout.Rigid(func(gtx layout.Context) layout.Dimensions {
128						return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceBetween}.Layout(gtx,
129							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
130								eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), "Edited: ")
131								eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
132								eDate.Alignment = text.Start
133								return eDate.Layout(gtx)
134							}),
135							layout.Rigid(func(gtx layout.Context) layout.Dimensions {
136								eDate := material.Label(th, unit.Sp(float32(*flagMetaSize)), fmt.Sprint(dateEdited))
137								eDate.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Normal}
138								eDate.Alignment = text.End
139								return eDate.Layout(gtx)
140							}),
141						)
142					}),
143				)
144			}),
145
146			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
147				size := image.Pt(gtx.Constraints.Max.X, 4)
148				defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
149				paint.ColorOp{Color: color.NRGBA{A: 0xFF}}.Add(gtx.Ops)
150				paint.PaintOp{}.Add(gtx.Ops)
151				return layout.Dimensions{Size: size}
152			}),
153
154			layout.Rigid(func(gtx layout.Context) layout.Dimensions {
155				gtx.Constraints.Max.X = int(float64(gtx.Constraints.Max.X) * .9)
156				gtx.Constraints.Min.X = gtx.Constraints.Max.X
157				sTitle := material.Label(th, unit.Sp(float32(*flagSiteTitleSize)), siteTitle)
158				sTitle.Font = text.Font{Typeface: "Primary font", Variant: "", Style: text.Regular, Weight: text.Bold}
159				sTitle.Alignment = text.Middle
160				return sTitle.Layout(gtx)
161			}),
162			layout.Rigid(layout.Spacer{Height: 50}.Layout),
163		)
164	})
165	img := image.NewRGBA(image.Rectangle{Max: sz})
166	err = w.Screenshot(img)
167	if err != nil {
168		fmt.Println("Error: Could not transform GUI to image")
169	}
170	var buf bytes.Buffer
171	if err := png.Encode(&buf, img); err != nil {
172		fmt.Println("Error: Could not encode image to PNG")
173	}
174	ioutil.WriteFile(*flagOutput, buf.Bytes(), 0o640)
175
176	os.Exit(0)
177}
178
179func fontCollection() []text.FontFace {
180	regularFaceBytes, err := fontEmbed.ReadFile("fonts/regular.otf")
181	if err != nil {
182		fmt.Println("Error: Could not read regular font")
183		fmt.Println(err)
184		os.Exit(1)
185	}
186	regularFace, err := opentype.Parse(regularFaceBytes)
187	if err != nil {
188		fmt.Println("Error: Could not parse regular font")
189		fmt.Println(err)
190		os.Exit(1)
191	}
192
193	boldFaceBytes, err := fontEmbed.ReadFile("fonts/bold.otf")
194	if err != nil {
195		fmt.Println("Error: Could not read bold font")
196		fmt.Println(err)
197		os.Exit(1)
198	}
199	boldFace, err := opentype.Parse(boldFaceBytes)
200	if err != nil {
201		fmt.Println("Error: Could not parse bold font")
202		fmt.Println(err)
203		os.Exit(1)
204	}
205
206	italicFaceBytes, err := fontEmbed.ReadFile("fonts/italic.otf")
207	if err != nil {
208		fmt.Println("Error: Could not read italic font")
209		fmt.Println(err)
210		os.Exit(1)
211	}
212	italicFace, err := opentype.Parse(italicFaceBytes)
213	if err != nil {
214		fmt.Println("Error: Could not parse italic font")
215		fmt.Println(err)
216		os.Exit(1)
217	}
218
219	boldItalicFaceBytes, err := fontEmbed.ReadFile("fonts/bold-italic.otf")
220	if err != nil {
221		fmt.Println("Error: Could not read bold italic font")
222		fmt.Println(err)
223		os.Exit(1)
224	}
225	boldItalicFace, err := opentype.Parse(boldItalicFaceBytes)
226	if err != nil {
227		fmt.Println("Error: Could not parse bold italic font")
228		fmt.Println(err)
229		os.Exit(1)
230	}
231
232	return []text.FontFace{
233		{
234			Font: text.Font{
235				Typeface: "Primary font",
236				Variant:  "",
237				Style:    text.Regular,
238				Weight:   text.Normal,
239			},
240			Face: regularFace,
241		},
242		{
243			Font: text.Font{
244				Typeface: "Primary font",
245				Variant:  "",
246				Style:    text.Regular,
247				Weight:   text.Bold,
248			},
249			Face: boldFace,
250		},
251		{
252			Font: text.Font{
253				Typeface: "Primary font",
254				Variant:  "",
255				Style:    text.Italic,
256				Weight:   text.Normal,
257			},
258			Face: italicFace,
259		},
260		{
261			Font: text.Font{
262				Typeface: "Primary font",
263				Variant:  "",
264				Style:    text.Italic,
265				Weight:   text.Bold,
266			},
267			Face: boldItalicFace,
268		},
269	}
270}
271
272// Print help message
273func help() {
274	fmt.Println("\nUsage: p2c [options]")
275	fmt.Println("\nOptions:")
276	flag.PrintDefaults()
277	fmt.Print(`
278example: p2c -i input.md -o output.png
279
280p2c is meant for use with Hugo.
281
282It looks at...
283- The Markdown file's frontmatter fields for
284  - title
285  - date
286- The site's config.{toml/yaml/yml} fields for
287  - title (site title)
288- The git history to determine the date the post was last edited.
289
290`)
291}
292
293// Validate flags
294func validateFlags() {
295	if *flagInput == "" {
296		fmt.Println("Error: No input file specified")
297		os.Exit(1)
298	}
299	if *flagOutput == "" {
300		fmt.Println("Error: No output file specified")
301		os.Exit(1)
302	}
303}
304
305// Get the post's title, subtitle, and date
306func getPostInfo(input string) (string, string, string) {
307	if _, err := os.Stat(input); os.IsNotExist(err) {
308		fmt.Println("Error: Input file does not exist")
309		fmt.Println(err)
310		os.Exit(1)
311	}
312
313	fileContents, err := os.ReadFile(input)
314	if err != nil {
315		fmt.Println("Error: Could not read input file")
316		fmt.Println(err)
317		os.Exit(1)
318	}
319
320	var fm struct {
321		Title string `yaml:"title"`
322		Date  string `yaml:"date"`
323	}
324
325	content, err := frontmatter.Parse(strings.NewReader(string(fileContents)), &fm)
326	if err != nil {
327		fmt.Println("Error: Could not parse frontmatter")
328		fmt.Println(err)
329		os.Exit(1)
330	}
331
332	fm.Date = strings.Split(fm.Date, "T")[0]
333
334	return fm.Title, fm.Date, string(content)
335}
336
337// Get the read time of the post
338func getReadTime(content string) int {
339	wordCount := len(strings.Fields(content))
340	return wordCount / 200
341}
342
343// Get the site's title
344func getSiteTitle() string {
345	validConf := ""
346	confs := []string{"config.toml", "config.yaml", "config.yml"}
347	for _, conf := range confs {
348		if _, err := os.Stat(conf); os.IsNotExist(err) {
349			continue
350		}
351		validConf = conf
352	}
353	if validConf == "" {
354		fmt.Println("Error: No valid config file found, please run this in the root of a Hugo site")
355		os.Exit(1)
356	}
357
358	var t struct {
359		Title string `yaml:"title"`
360	}
361
362	f, err := os.Open(validConf)
363	if err != nil {
364		fmt.Println("Error: Could not open config file")
365		fmt.Println(err)
366		os.Exit(1)
367	}
368	defer f.Close()
369
370	decoder := yaml.NewDecoder(f)
371	err = decoder.Decode(&t)
372	if err != nil {
373		fmt.Println("Error: Could not parse config file")
374		fmt.Println(err)
375		os.Exit(1)
376	}
377
378	return t.Title
379}
380
381// Get the date the post was last edited
382func getGitDate(input string) string {
383	if _, err := os.Stat(input); os.IsNotExist(err) {
384		fmt.Println("Error: Input file does not exist")
385		os.Exit(1)
386	}
387
388	repo, err := git.PlainOpenWithOptions(".", &git.PlainOpenOptions{DetectDotGit: true})
389	if err != nil {
390		fmt.Println("Error: Could not open git repository")
391		fmt.Println(err)
392		os.Exit(1)
393	}
394
395	commitIter, err := repo.Log(&git.LogOptions{FileName: &input})
396	if err != nil {
397		fmt.Println("Error: Could not get git history")
398		fmt.Println(err)
399		os.Exit(1)
400	}
401
402	commit, err := commitIter.Next()
403	if err != nil {
404		fmt.Println("Error: Could not get git history")
405		fmt.Println(err)
406		os.Exit(1)
407	}
408
409	return commit.Committer.When.Format("2006-01-02")
410}