blob: 6591f1ce7f15405c01709113fd48b45074b3bc7f [file] [log] [blame]
// Copyright 2020 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ppdTool is a command line tool that can:
// * download all PPD files from the database kept on the SCS server;
// * cluster given set of PPD files and return a minimal subset of PPDs that
// represents resultant clusters. This is useful for choosing a subset of
// PPD files for testing.
//
// The tool can be run with the command:
// go run ppdTool.go
// Use -h parameter to print some help and list of accepted parameters.
//
// The tool can be also compiled to the binary file with the following command:
// go build pdfTool.go
package main
import (
"bufio"
"bytes"
"compress/gzip"
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
)
// downloadFile starts to download the content from given url with HTTP GET. It
// returns a reader to the content. In case of an error the function terminates
// the program.
func downloadFile(url string) io.ReadCloser {
response, err := http.Get(url)
if err != nil {
log.Fatalf("Cannot HTTP GET the file %s: %s.\n", url, err)
}
if response.StatusCode != 200 {
response.Body.Close()
log.Fatalf("HTTP GET for the file %s returned status code %d.\n", url, response.StatusCode)
}
return response.Body
}
// downloadFilenamesFromPPDIndex retrieves from the index a list of all PPD
// files. Returned PPD filenames are sorted and unique. In case of an error
// the function terminates the program.
func downloadFilenamesFromPPDIndex() []string {
const urlMetadata = "https://printerconfigurations.googleusercontent.com/chromeos_printing/metadata_v3/"
output := make(map[string]bool)
for i := 0; i < 20; i++ {
// Calculate a URL of the index file.
urlPPDIndex := fmt.Sprintf("%sindex-%02d.json", urlMetadata, i)
// Download and parse the index file.
respBody := downloadFile(urlPPDIndex)
defer respBody.Close()
body, err := ioutil.ReadAll(respBody)
if err != nil {
log.Fatalf("Cannot read the content of %s: %s.\n", urlPPDIndex, err)
}
// Parse the json structure and extract PPD filenames.
type jsonName struct {
Name string `json:"name"`
}
type jsonMetadata struct {
PPDMetadata []jsonName `json:"ppdMetadata"`
}
type jsonPrinters struct {
PPDIndex map[string]jsonMetadata `json:"ppdIndex"`
}
var data jsonPrinters
if err = json.Unmarshal(body, &data); err != nil {
log.Fatalf("Cannot parse the content of %s: %s.\n", urlPPDIndex, err)
}
for _, entry := range data.PPDIndex {
for _, element := range entry.PPDMetadata {
output[element.Name] = true
}
}
}
// Sort filenames.
results := make([]string, 0, len(output))
for filename := range output {
results = append(results, filename)
}
sort.Strings(results)
return results
}
// listFilenamesFromDirectory returns a list of filenames from the given
// directory. In case of an error the function terminates the program.
func listFilenamesFromDirectory(path string) []string {
files, err := ioutil.ReadDir(path)
if err != nil {
log.Fatalf("Cannot open the directory %s: %s.\n", path, err)
}
filenames := make([]string, 0, len(files))
for _, file := range files {
if !file.IsDir() {
filenames = append(filenames, file.Name())
}
}
return filenames
}
// Statement represents a single statement from a PPD file.
type Statement struct {
keyword string
option string
value string
}
// PPD represents a content of a single PPD file as an array of Statements.
// The field name holds the filename of the PPD file while the field
// originalDataSize holds the initial size of the field data.
type PPD struct {
name string
data []Statement
originalDataSize int
}
var reComment = regexp.MustCompile(`^\*[ \t]*%`)
var reKeywordOptionValue = regexp.MustCompile(`^\*[ \t]*([^: \t]+)([ \t]+[^:]+)?[ \t]*:[ \t]*([^ \t].*)?$`)
var reKeywordOnly = regexp.MustCompile(`^\*[ \t]*([^: \t]+)[ \t]*$`)
var reEmptyLine = regexp.MustCompile(`^[ \t]*$`)
// parseLine parses a single line from PPD file. The line is supposed to be the
// first line of statement's definition. If the line contains white characters
// only or is a comment the function returns empty Statement (st.keyword == "")
// and finish with success (ok == true).
func parseLine(line string) (st Statement, ok bool) {
if reComment.MatchString(line) {
return st, true
}
if m := reKeywordOptionValue.FindStringSubmatch(line); m != nil {
st.keyword = m[1]
st.option = m[2]
st.value = m[3]
return st, true
}
if m := reKeywordOnly.FindStringSubmatch(line); m != nil {
st.keyword = m[1]
return st, true
}
if reEmptyLine.MatchString(line) {
return st, true
}
return st, false
}
// ParsePPD parses a content of a PPD file. The parameter name is the filename
// of the PPD file (the source of the content).
func ParsePPD(name string, content []byte) (PPD, error) {
ppd := PPD{name: name, data: make([]Statement, 0, 512)}
scanner := bufio.NewScanner(bytes.NewReader(content))
var multilineValue = false
for lineNo := 1; scanner.Scan(); lineNo++ {
line := scanner.Text()
if multilineValue {
// We are inside a multiline value.
ppd.data[len(ppd.data)-1].value += "\n" + line
// Check for closing ".
multilineValue = (strings.Count(line, "\"")%2 == 0)
continue
}
st, ok := parseLine(line)
if !ok {
return ppd, fmt.Errorf("Cannot parse line %d: %s", lineNo, line)
}
if st.keyword == "" {
// A comment or an empty line.
continue
}
ppd.data = append(ppd.data, st)
// Check for unmatched " in the value.
multilineValue = (strings.Count(st.value, "\"")%2 != 0)
}
ppd.originalDataSize = len(ppd.data)
return ppd, scanner.Err()
}
var reWhiteSpaces = regexp.MustCompile(`[ \t]+`)
// normalizeSpacesAndTabs normalizes subsequences of spaces and tabulators in
// the given string. All leading and trailing spaces and tabs are removed.
// Every subsequence consisting of spaces and tabulators is replaced by a
// single space.
func normalizeSpacesAndTabs(str *string) {
*str = strings.TrimSpace(*str)
*str = reWhiteSpaces.ReplaceAllString(*str, " ")
}
var keywordsToRemove = map[string]bool{
"1284DeviceID": true,
"cupsLanguages": true,
"cupsVersion": true,
"DefaultDocCutType": true,
"DefaultInstalledMemory": true,
"DefaultPageCutType": true,
"DocCutType": true,
"driverUrl": true,
"End": true,
"FileVersion": true,
"FoomaticIDs": true,
"InstalledMemory": true,
"Manufacturer": true,
"ModelName": true,
"NickName": true,
"PageCutType": true,
"PCFileName": true,
"Product": true,
"ShortNickName": true,
"Throughput": true}
var shortLang = regexp.MustCompile(`^[a-z][a-z]\.`)
var longLang = regexp.MustCompile(`^[a-z][a-z]_[A-Za-z][A-Za-z]\.`)
// normalizePPD processes the given PPD content to make it suitable for
// comparison with other PPDs. The PPD may be no longer valid after this
// transformation. The following operations are performed on the PPD:
// * all statements with keyword included in the global variable
// keywordsToRemove are removed;
// * all statements with keyword with prefix matching ^[a-z][a-z]\. or
// ^[a-z][a-z]_[A-Za-z][A-Za-z]\. are removed (like *pl.MediaType,
// *de.Translation, *fr_CA.Translation, *zh_TW.MediaType, etc.);
// * subsequences of white spaces in all statements are normalized with
// the use of normalizeSpacesAndTabs(...)
func normalizePPD(ppd *PPD) {
newData := make([]Statement, 0, len(ppd.data))
for _, s := range ppd.data {
if keywordsToRemove[s.keyword] {
continue
}
if shortLang.MatchString(s.keyword) || longLang.MatchString(s.keyword) {
continue
}
normalizeSpacesAndTabs(&s.option)
normalizeSpacesAndTabs(&s.value)
newData = append(newData, s)
}
ppd.data = newData
}
// parseAndNormalizePPDFile reads the content of a PPD file from the given
// reader and parses it. The content is also normalized with the normalizePPD
// function. In case of an error the function terminates the program.
func parseAndNormalizePPDFile(reader io.ReadCloser, filename string) PPD {
// Decompress the content if needed.
if strings.HasSuffix(filename, ".gz") {
defer reader.Close()
decomp, err := gzip.NewReader(reader)
if err != nil {
log.Fatalf("Error when decompressing the file %s: %s.\n", filename, err)
}
reader = decomp
}
defer reader.Close()
content, err := ioutil.ReadAll(reader)
if err != nil {
log.Fatalf("Error when reading a content of the file %s: %s.\n", filename, err)
}
ppd, err := ParsePPD(filename, content)
if err != nil {
log.Fatalf("Error when parsing a content of the file %s: %s.\n", filename, err)
}
normalizePPD(&ppd)
return ppd
}
// checkNotExists terminates the program when the given path exists.
func checkNotExists(path string) {
_, err := os.Stat(path)
if os.IsNotExist(err) {
return
}
if err == nil {
log.Fatal("File or directory '" + path + "' already exists.")
}
log.Fatalf("Cannot access '%s': %s.\n", path, err)
}
// divideIntoLargeClusters divides the input set of PPDs into clusters of PPDs
// with the same content (data). The output slice contains the resultant
// clusters saved as a list of PPD names.
func divideIntoLargeClusters(ppds []PPD) [][]string {
type ppdTypeDefinition struct {
cupsFilter string
cupsModelNumber string
cupsPreFilter string
driverName string
driverType string
foomaticRIPCommandLine string
}
groups := make(map[ppdTypeDefinition][]int)
for iPPD, ppd := range ppds {
chosenKeywords := make(map[string][]string)
for _, st := range ppd.data {
switch st.keyword {
case "cupsFilter", "cupsFilter2", "cupsModelNumber", "cupsPreFilter", "FoomaticRIPCommandLine":
chosenKeywords[st.keyword] = append(chosenKeywords[st.keyword], st.value)
case "driverName", "driverType":
chosenKeywords[st.keyword] = append(chosenKeywords[st.keyword], st.option)
}
}
if values, ok := chosenKeywords["cupsFilter2"]; ok {
chosenKeywords["cupsFilter"] = values
delete(chosenKeywords, "cupsFilter2")
}
var hash ppdTypeDefinition
for keyword, values := range chosenKeywords {
sort.Slice(values, func(i, j int) bool { return values[i] < values[j] })
switch keyword {
case "cupsFilter":
hash.cupsFilter = strings.Join(values, " | ")
case "cupsModelNumber":
hash.cupsModelNumber = strings.Join(values, " | ")
case "cupsPreFilter":
hash.cupsPreFilter = strings.Join(values, " | ")
case "driverName":
hash.driverName = strings.Join(values, " | ")
case "driverType":
hash.driverType = strings.Join(values, " | ")
case "FoomaticRIPCommandLine":
hash.foomaticRIPCommandLine = strings.Join(values, " | ")
}
}
groups[hash] = append(groups[hash], iPPD)
}
// Sort every group by originalDataSize(decreasing), name(alphabetically).
for _, ppdIDs := range groups {
sort.Slice(ppdIDs, func(i, j int) bool {
p1 := ppdIDs[i]
p2 := ppdIDs[j]
if ppds[p1].originalDataSize == ppds[p2].originalDataSize {
return ppds[p1].name < ppds[p2].name
}
return ppds[p1].originalDataSize > ppds[p2].originalDataSize
})
}
// Convert groups to a slice of slices with names.
groupsSlice := make([][]string, 0, len(groups))
for _, group := range groups {
names := make([]string, len(group))
for i, iPPD := range group {
names[i] = ppds[iPPD].name
}
groupsSlice = append(groupsSlice, names)
}
sort.Slice(groupsSlice, func(i, j int) bool {
return groupsSlice[i][0] < groupsSlice[j][0]
})
return groupsSlice
}
// compareSameSizePPDs is a helper function for divideIntoSmallClusters. It
// divides the set of PPDs into clusters of PPDs with the same data. The input
// PPDs must have the same size of data field. The function returns resultant
// clusters as slices with PPDs names.
func compareSameSizePPDs(ppds []PPD) [][]string {
// This map holds PPDID->groupID. At the beginning, every PPD is assigned
// to a one-element group.
ppdsGroups := make([]int, len(ppds))
for i := range ppdsGroups {
ppdsGroups[i] = i
}
// Find PPDs with the same data and assign them to the same group.
for i1, e1 := range ppds {
if ppdsGroups[i1] != i1 {
// This PPD was already assigned.
continue
}
for i2 := i1 + 1; i2 < len(ppds); i2++ {
e2 := ppds[i2]
if ppdsGroups[i2] != i2 {
// This PPD was already assigned.
continue
}
// Compare data.
match := true
for ip, s1 := range e1.data {
s2 := e2.data[ip]
if s1 != s2 {
match = false
break
}
}
if match {
// Assign i2 to the same group as i1.
ppdsGroups[i2] = i1
}
}
}
// This map contains groupID->[]PPDID.
groups := make(map[int][]int)
for iPPD, iGroup := range ppdsGroups {
groups[iGroup] = append(groups[iGroup], iPPD)
}
// Sort every group by originalDataSize(decreasing), name(alphabetically).
for _, ppdIDs := range groups {
sort.Slice(ppdIDs, func(i, j int) bool {
p1 := ppdIDs[i]
p2 := ppdIDs[j]
if ppds[p1].originalDataSize == ppds[p2].originalDataSize {
return ppds[p1].name < ppds[p2].name
}
return ppds[p1].originalDataSize > ppds[p2].originalDataSize
})
}
// Convert groups to a slice of slices with names.
groupsSlice := make([][]string, 0, len(groups))
for _, group := range groups {
names := make([]string, len(group))
for i, iPPD := range group {
names[i] = ppds[iPPD].name
}
groupsSlice = append(groupsSlice, names)
}
return groupsSlice
}
// divideIntoSmallClusters divides the input set of PPDs into clusters of PPDs
// with the same content (data). The output slice contains the resultant
// clusters saved as a list of PPD names.
func divideIntoSmallClusters(ppds []PPD) [][]string {
type ppdHash struct {
dataSize int
firstStatement Statement
middleStatement Statement
lastStatement Statement
}
ppdsByHash := make(map[ppdHash][]PPD)
for _, ppd := range ppds {
var hash ppdHash
hash.dataSize = len(ppd.data)
hash.firstStatement = ppd.data[0]
hash.middleStatement = ppd.data[len(ppd.data)/2]
hash.lastStatement = ppd.data[len(ppd.data)-1]
ppdsByHash[hash] = append(ppdsByHash[hash], ppd)
}
chGroups := make(chan [][]string, len(ppdsByHash))
for _, ppdsToCompare := range ppdsByHash {
go func(ppdsToCompare []PPD) {
chGroups <- compareSameSizePPDs(ppdsToCompare)
}(ppdsToCompare)
}
var groups [][]string
for range ppdsByHash {
groups = append(groups, <-chGroups...)
}
close(chGroups)
sort.Slice(groups, func(i, j int) bool {
return groups[i][0] < groups[j][0]
})
return groups
}
// saveClustersToFile creates a new file at given path and saves there the
// given list of clusters. In case of any error the function terminates the
// program.
func saveClustersToFile(clusters [][]string, path string) {
file, err := os.Create(path)
if err != nil {
log.Fatalf("Cannot create a file %s: %s.\n", path, err)
}
defer file.Close()
for _, cluster := range clusters {
file.WriteString(strings.Join(cluster, "\t"))
file.WriteString("\n")
}
}
// createDirectoryWithPPDs creates directory given in the parameter pathTrg and
// copies there the given set of files from the directory defined in pathSrc.
// In case of any error the function terminates the program.
func createDirectoryWithPPDs(pathSrc string, filenames []string, pathTrg string) {
if err := os.MkdirAll(pathTrg, 0755); err != nil {
log.Fatalf("Cannot create a directory '%s': %s.\n", pathTrg, err)
}
for _, filename := range filenames {
src := filepath.Join(pathSrc, filename)
trg := filepath.Join(pathTrg, filename)
if err := os.Link(src, trg); err != nil {
log.Fatalf("Cannot create a hard link %s for the file %s: %s.\n", trg, src, err)
}
}
}
func commandCompare(args []string) {
const filenameLargeClusters = "large_clusters.txt"
const filenameSmallClusters = "small_clusters.txt"
const dirnameCorePPDs = "ppds_core"
const dirnameExtPPDs = "ppds_ext"
flags := flag.NewFlagSet("compare", flag.ExitOnError)
flagInput := flags.String("input", "ppds_all", "Directory with PPD files.")
flagOutput := flags.String("output", ".", "Directory to save results. It is created if not exists.")
flags.Parse(args)
if len(flags.Args()) > 0 {
log.Fatal("Unknown parameter. Run with -h or --help to see the list of supported parameters.")
}
pathLargeClusters := filepath.Join(*flagOutput, filenameLargeClusters)
pathSmallClusters := filepath.Join(*flagOutput, filenameSmallClusters)
pathCorePPDs := filepath.Join(*flagOutput, dirnameCorePPDs)
pathExtPPDs := filepath.Join(*flagOutput, dirnameExtPPDs)
checkNotExists(pathLargeClusters)
checkNotExists(pathSmallClusters)
checkNotExists(pathCorePPDs)
checkNotExists(pathExtPPDs)
fmt.Println("Reading a list of PPD files from the directory...")
filenames := listFilenamesFromDirectory(*flagInput)
fmt.Printf("Found %d files.\n", len(filenames))
fmt.Println("Processing all files...")
ppds := make([]PPD, len(filenames))
var wg sync.WaitGroup
for i, filename := range filenames {
wg.Add(1)
go func(i int, filename string) {
defer wg.Done()
path := filepath.Join(*flagInput, filename)
reader, err := os.Open(path)
if err != nil {
log.Fatalf("Cannot open the file %s: %s.\n", path, err)
}
ppds[i] = parseAndNormalizePPDFile(reader, filename)
}(i, filename)
}
wg.Wait()
fmt.Println("Done.")
fmt.Println("Calculating small clusters...")
groupsSmall := divideIntoSmallClusters(ppds)
fmt.Printf("Done. The number of small clusters: %d.\n", len(groupsSmall))
fmt.Println("Calculating large clusters...")
groupsLarge := divideIntoLargeClusters(ppds)
fmt.Printf("Done. The number of large clusters: %d.\n", len(groupsLarge))
filenamesCore := make([]string, 0, len(groupsLarge))
setFilenameCore := make(map[string]bool)
for _, group := range groupsLarge {
filenamesCore = append(filenamesCore, group[0])
setFilenameCore[group[0]] = true
}
filenamesExt := make([]string, 0, len(groupsSmall))
for _, group := range groupsSmall {
if !setFilenameCore[group[0]] {
filenamesExt = append(filenamesExt, group[0])
}
}
// Save results.
createDirectoryWithPPDs(*flagInput, filenamesCore, pathCorePPDs)
createDirectoryWithPPDs(*flagInput, filenamesExt, pathExtPPDs)
saveClustersToFile(groupsSmall, pathSmallClusters)
saveClustersToFile(groupsLarge, pathLargeClusters)
}
func commandDownload(args []string) {
const urlPPD = "https://printerconfigurations.googleusercontent.com/chromeos_printing/ppds_for_metadata_v3/"
const maxNumberOfParallelDownloads = 4
flags := flag.NewFlagSet("download", flag.ExitOnError)
flagOutput := flags.String("output", "ppds_all", "Directory to save PPD files, it cannot exist.")
flags.Parse(args)
if len(flags.Args()) > 0 {
log.Fatal("Unknown parameter. Run with -h or --help to see the list of supported parameters.")
}
checkNotExists(*flagOutput)
if err := os.MkdirAll(*flagOutput, 0755); err != nil {
log.Fatalf("Cannot create a directory '%s': %s.\n", *flagOutput, err)
}
fmt.Println("Downloading a list of PPD files from the index...")
filenames := downloadFilenamesFromPPDIndex()
fmt.Printf("Found %d files.\n", len(filenames))
fmt.Println("Downloading PPD files...")
chFilenames := make(chan string)
var wgEnd sync.WaitGroup
for i := 0; i < maxNumberOfParallelDownloads; i++ {
wgEnd.Add(1)
go func() {
defer wgEnd.Done()
for filename := range chFilenames {
reader := downloadFile(urlPPD + filename)
path := filepath.Join(*flagOutput, filename)
file, err := os.Create(path)
if err != nil {
log.Fatalf("Cannot create file %s on the disk: %s.\n", path, err)
}
if _, err = io.Copy(file, reader); err != nil {
log.Fatalf("Cannot copy the content of the file %s: %s.\n", path, err)
}
reader.Close()
file.Close()
}
}()
}
for _, filename := range filenames {
chFilenames <- filename
}
close(chFilenames)
wgEnd.Wait()
fmt.Println("Done")
}
const usageText = `
The first parameter must be one of the following commands:
download - downloads all PPDs from the index to the given directory.
compare - perform two independent clusterizations on the given set of PPD
files. Two sets of clusters are calculated:
* a set of large clusters where PPD are grouped together by pipeline
types;
* a set of small clusters where PPD are grouped together by their
similarity.
For both results a minimal subsets of representative PPDs are calculated.
In the output directory, the following files and directories are created:
* large_clusters.txt - a file with PPD names grouped in large clusters
* small_clusters.txt - a file with PPD names grouped in small clusters
* ppds_core - a directory with hard links to PPD files representing
large clusters, each cluster is represented by exactly one PPD file.
For the full PPD dataset given on the input, this directory is
supposed to have around ~100 PPD files;
* ppds_ext - a directory with hard links to PPD files representing
small clusters, each cluster is represented by exactly one PPD file.
IF A PPD FILE IS ALREADY PRESENT IN core_ppds IT IS OMITTED. For the
full PPD dataset given on the input, this directory is supposed to
have around ~1500 PPD files minus ~100 PPD files already present in
the core_ppd directory.
Run one of the commands with '-h' or '--help' to get a list of parameters.
`
func main() {
if len(os.Args) < 2 {
fmt.Println(usageText)
return
}
switch os.Args[1] {
case "compare":
commandCompare(os.Args[2:])
case "download":
commandDownload(os.Args[2:])
default:
fmt.Println(usageText)
}
}